From 5296d0e97ce04781e71335d8d95b59ba9d59a8aa Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:35:44 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .env.example | 256 ++ .github/dependabot.yml | 13 + .github/workflows/tests.yml | 74 + .gitignore | 17 + .python-version | 1 + AGENTS.md | 105 + ARCHITECTURE.md | 1294 +++++++++ CLAUDE.md | 105 + CONTRIBUTING.md | 63 + LICENSE | 21 + README.md | 431 +++ README.wehub.md | 7 + assets/admin-messaging.png | Bin 0 -> 144665 bytes assets/admin-page.png | Bin 0 -> 80752 bytes assets/cc-model-picker.png | Bin 0 -> 154762 bytes assets/codex-model-picker.png | Bin 0 -> 113365 bytes assets/codex.png | Bin 0 -> 133586 bytes assets/how-it-works.mmd | 57 + assets/how-it-works.svg | 1 + assets/pic.png | Bin 0 -> 136351 bytes pyproject.toml | 131 + scripts/ci.ps1 | 210 ++ scripts/ci.sh | 212 ++ scripts/install.ps1 | 557 ++++ scripts/install.sh | 499 ++++ scripts/uninstall.ps1 | 271 ++ scripts/uninstall.sh | 243 ++ smoke/README.md | 184 ++ smoke/__init__.py | 1 + smoke/capabilities.py | 524 ++++ smoke/conftest.py | 126 + smoke/features.py | 565 ++++ smoke/lib/__init__.py | 1 + smoke/lib/child_process.py | 70 + smoke/lib/claude_cli_matrix.py | 786 ++++++ smoke/lib/config.py | 445 +++ smoke/lib/e2e.py | 756 +++++ smoke/lib/http.py | 69 + smoke/lib/local_providers.py | 72 + smoke/lib/report.py | 105 + smoke/lib/report_summary.py | 51 + smoke/lib/server.py | 118 + smoke/lib/skips.py | 67 + smoke/prereq/__init__.py | 1 + smoke/prereq/test_api_prereq_live.py | 194 ++ smoke/prereq/test_auth_prereq_live.py | 50 + smoke/prereq/test_cli_prereq_live.py | 82 + .../prereq/test_client_shapes_prereq_live.py | 47 + ...st_local_provider_endpoints_prereq_live.py | 34 + smoke/prereq/test_messaging_prereq_live.py | 111 + smoke/prereq/test_provider_prereq_live.py | 104 + smoke/prereq/test_tools_prereq_live.py | 61 + smoke/prereq/test_voice_prereq_live.py | 62 + smoke/product/__init__.py | 1 + smoke/product/test_api_product_live.py | 200 ++ smoke/product/test_auth_product_live.py | 51 + .../product/test_cli_package_product_live.py | 116 + smoke/product/test_client_product_live.py | 286 ++ .../test_config_extensibility_product_live.py | 176 ++ .../test_live_platform_product_live.py | 125 + .../test_local_provider_product_live.py | 58 + smoke/product/test_messaging_product_live.py | 484 ++++ .../test_nvidia_nim_cli_product_live.py | 68 + .../test_openrouter_free_cli_product_live.py | 70 + smoke/product/test_provider_product_live.py | 583 ++++ .../test_runtime_ownership_product_live.py | 311 ++ smoke/product/test_voice_product_live.py | 68 + src/free_claude_code/__init__.py | 1 + src/free_claude_code/api/__init__.py | 1 + src/free_claude_code/api/admin_routes.py | 200 ++ .../api/admin_static/admin.css | 736 +++++ .../api/admin_static/admin.js | 475 ++++ .../api/admin_static/index.html | 77 + src/free_claude_code/api/app.py | 118 + src/free_claude_code/api/command_utils.py | 164 ++ src/free_claude_code/api/dependencies.py | 74 + src/free_claude_code/api/detection.py | 150 + src/free_claude_code/api/handlers/__init__.py | 7 + src/free_claude_code/api/handlers/messages.py | 358 +++ .../api/handlers/responses.py | 183 ++ .../api/handlers/token_count.py | 85 + src/free_claude_code/api/model_catalog.py | 152 + .../api/optimization_handlers.py | 157 ++ src/free_claude_code/api/ports.py | 32 + src/free_claude_code/api/request_errors.py | 99 + src/free_claude_code/api/request_ids.py | 98 + src/free_claude_code/api/response_streams.py | 386 +++ src/free_claude_code/api/routes.py | 221 ++ src/free_claude_code/api/validation_log.py | 46 + .../api/web_tools/__init__.py | 17 + .../api/web_tools/constants.py | 17 + src/free_claude_code/api/web_tools/egress.py | 105 + .../api/web_tools/outbound.py | 276 ++ src/free_claude_code/api/web_tools/parsers.py | 102 + src/free_claude_code/api/web_tools/request.py | 76 + .../api/web_tools/streaming.py | 204 ++ src/free_claude_code/application/__init__.py | 1 + src/free_claude_code/application/errors.py | 41 + src/free_claude_code/application/execution.py | 147 + .../application/model_metadata.py | 11 + src/free_claude_code/application/ports.py | 77 + src/free_claude_code/application/routing.py | 157 ++ src/free_claude_code/cli/__init__.py | 5 + src/free_claude_code/cli/claude_env.py | 32 + src/free_claude_code/cli/entrypoints.py | 176 ++ .../cli/launchers/__init__.py | 1 + src/free_claude_code/cli/launchers/claude.py | 64 + src/free_claude_code/cli/launchers/codex.py | 208 ++ .../cli/launchers/codex_model_catalog.py | 184 ++ src/free_claude_code/cli/launchers/common.py | 91 + src/free_claude_code/cli/launchers/pi.py | 163 ++ .../cli/launchers/pi_extension.ts | 157 ++ src/free_claude_code/cli/managed/__init__.py | 6 + src/free_claude_code/cli/managed/claude.py | 215 ++ .../cli/managed/diagnostics.py | 47 + src/free_claude_code/cli/managed/manager.py | 207 ++ src/free_claude_code/cli/managed/session.py | 293 ++ src/free_claude_code/cli/process_registry.py | 76 + src/free_claude_code/cli/proxy_auth.py | 9 + src/free_claude_code/config/__init__.py | 5 + src/free_claude_code/config/admin/__init__.py | 1 + src/free_claude_code/config/admin/manifest.py | 698 +++++ .../config/admin/persistence.py | 218 ++ .../config/admin/provider_manifest.py | 201 ++ src/free_claude_code/config/admin/sources.py | 84 + src/free_claude_code/config/admin/status.py | 54 + .../config/admin/validation.py | 46 + src/free_claude_code/config/admin/values.py | 113 + src/free_claude_code/config/constants.py | 7 + src/free_claude_code/config/env_files.py | 91 + src/free_claude_code/config/env_migrations.py | 129 + src/free_claude_code/config/env_template.py | 29 + src/free_claude_code/config/logging_config.py | 159 ++ src/free_claude_code/config/model_refs.py | 61 + src/free_claude_code/config/nim.py | 118 + src/free_claude_code/config/paths.py | 52 + .../config/provider_catalog.py | 284 ++ src/free_claude_code/config/server_urls.py | 26 + src/free_claude_code/config/settings.py | 403 +++ src/free_claude_code/core/__init__.py | 1 + .../core/anthropic/__init__.py | 100 + .../core/anthropic/content.py | 31 + .../core/anthropic/conversion.py | 604 ++++ src/free_claude_code/core/anthropic/errors.py | 87 + src/free_claude_code/core/anthropic/models.py | 250 ++ .../core/anthropic/request_serialization.py | 57 + .../core/anthropic/request_snapshot.py | 36 + .../core/anthropic/server_tool_sse.py | 12 + .../core/anthropic/sse_aggregation.py | 128 + .../core/anthropic/stream_contracts.py | 206 ++ .../core/anthropic/streaming/__init__.py | 39 + .../core/anthropic/streaming/emitter.py | 62 + .../core/anthropic/streaming/ledger.py | 554 ++++ .../core/anthropic/streaming/recovery.py | 203 ++ .../core/anthropic/thinking.py | 140 + src/free_claude_code/core/anthropic/tokens.py | 118 + src/free_claude_code/core/anthropic/tools.py | 212 ++ src/free_claude_code/core/anthropic/utils.py | 9 + src/free_claude_code/core/async_iterators.py | 26 + src/free_claude_code/core/diagnostics.py | 301 ++ src/free_claude_code/core/failures.py | 49 + .../core/gateway_model_ids.py | 52 + .../core/openai_responses/__init__.py | 17 + .../core/openai_responses/adapter.py | 39 + .../core/openai_responses/anthropic_sse.py | 69 + .../core/openai_responses/errors.py | 67 + .../core/openai_responses/events.py | 17 + .../core/openai_responses/ids.py | 19 + .../core/openai_responses/input.py | 357 +++ .../core/openai_responses/items.py | 35 + .../core/openai_responses/models.py | 26 + .../core/openai_responses/reasoning.py | 54 + .../core/openai_responses/stream.py | 92 + .../openai_responses/streaming/__init__.py | 5 + .../openai_responses/streaming/assembler.py | 354 +++ .../core/openai_responses/streaming/blocks.py | 36 + .../openai_responses/streaming/completion.py | 154 + .../streaming/error_mapping.py | 28 + .../streaming/event_builders.py | 182 ++ .../core/openai_responses/streaming/ledger.py | 84 + .../core/openai_responses/tools.py | 375 +++ .../core/openai_responses/usage.py | 35 + src/free_claude_code/core/rate_limit.py | 72 + src/free_claude_code/core/trace.py | 196 ++ src/free_claude_code/core/version.py | 15 + src/free_claude_code/messaging/__init__.py | 16 + .../messaging/cli_event_constants.py | 67 + .../messaging/command_context.py | 99 + .../messaging/command_dispatcher.py | 31 + src/free_claude_code/messaging/commands.py | 182 ++ .../messaging/event_parser.py | 184 ++ src/free_claude_code/messaging/limiter.py | 342 +++ .../messaging/managed_protocols.py | 35 + src/free_claude_code/messaging/models.py | 57 + .../messaging/node_event_pipeline.py | 120 + src/free_claude_code/messaging/node_runner.py | 408 +++ .../messaging/platforms/__init__.py | 20 + .../messaging/platforms/discord.py | 319 +++ .../messaging/platforms/discord_inbound.py | 112 + .../messaging/platforms/discord_io.py | 167 ++ .../messaging/platforms/factory.py | 109 + .../messaging/platforms/outbox.py | 143 + .../messaging/platforms/ports.py | 99 + .../messaging/platforms/telegram.py | 300 ++ .../messaging/platforms/telegram_inbound.py | 133 + .../messaging/platforms/telegram_io.py | 287 ++ .../messaging/platforms/voice_flow.py | 388 +++ .../messaging/rendering/__init__.py | 3 + .../messaging/rendering/discord_markdown.py | 318 +++ .../messaging/rendering/markdown_tables.py | 47 + .../messaging/rendering/profiles.py | 53 + .../messaging/rendering/telegram_markdown.py | 327 +++ .../messaging/safe_diagnostics.py | 15 + .../messaging/session/__init__.py | 5 + .../messaging/session/managed_message_log.py | 135 + .../messaging/session/persistence.py | 161 ++ .../messaging/session/store.py | 162 ++ .../messaging/transcript/__init__.py | 6 + .../messaging/transcript/buffer.py | 220 ++ .../messaging/transcript/context.py | 18 + .../messaging/transcript/renderer.py | 65 + .../messaging/transcript/segments.py | 176 ++ .../messaging/transcript/subagents.py | 108 + .../messaging/transcription.py | 132 + .../messaging/trees/__init__.py | 41 + src/free_claude_code/messaging/trees/graph.py | 250 ++ .../messaging/trees/identity.py | 16 + .../messaging/trees/manager.py | 587 ++++ src/free_claude_code/messaging/trees/node.py | 56 + .../messaging/trees/processor.py | 268 ++ src/free_claude_code/messaging/trees/queue.py | 47 + .../messaging/trees/repository.py | 135 + .../messaging/trees/runtime.py | 487 ++++ .../messaging/trees/snapshot.py | 221 ++ .../messaging/trees/transitions.py | 174 ++ src/free_claude_code/messaging/turn_intake.py | 241 ++ src/free_claude_code/messaging/ui_updates.py | 99 + src/free_claude_code/messaging/voice.py | 376 +++ src/free_claude_code/messaging/workflow.py | 714 +++++ src/free_claude_code/providers/__init__.py | 12 + src/free_claude_code/providers/base.py | 136 + .../providers/cloudflare/__init__.py | 8 + .../providers/cloudflare/client.py | 168 ++ .../providers/deepseek/__init__.py | 5 + .../providers/deepseek/client.py | 46 + .../providers/deepseek/compat.py | 432 +++ .../providers/failure_policy.py | 393 +++ .../providers/gemini/__init__.py | 5 + .../providers/gemini/client.py | 62 + .../providers/gemini/quirks.py | 171 ++ .../providers/github_models/__init__.py | 5 + .../providers/github_models/client.py | 147 + src/free_claude_code/providers/http.py | 51 + .../providers/lmstudio/__init__.py | 5 + .../providers/lmstudio/client.py | 127 + .../providers/mistral/__init__.py | 5 + .../providers/mistral/client.py | 68 + .../providers/mistral/reasoning.py | 408 +++ .../providers/model_listing.py | 107 + .../providers/nvidia_nim/__init__.py | 5 + .../providers/nvidia_nim/client.py | 147 + .../providers/nvidia_nim/request_options.py | 108 + .../providers/nvidia_nim/retry.py | 72 + .../providers/nvidia_nim/tool_schema.py | 255 ++ .../providers/nvidia_nim/voice.py | 113 + .../providers/open_router/__init__.py | 5 + .../providers/open_router/client.py | 231 ++ .../providers/openai_chat/__init__.py | 40 + .../providers/openai_chat/base_url.py | 7 + .../providers/openai_chat/extra_body.py | 32 + .../providers/openai_chat/output_cap.py | 83 + .../providers/openai_chat/profiles.py | 229 ++ .../providers/openai_chat/provider.py | 837 ++++++ .../providers/openai_chat/request_policy.py | 123 + .../providers/openai_chat/tool_calls.py | 285 ++ .../providers/openai_chat/usage.py | 98 + src/free_claude_code/providers/rate_limit.py | 233 ++ .../providers/runtime/__init__.py | 11 + .../providers/runtime/config.py | 68 + .../providers/runtime/discovery.py | 99 + .../providers/runtime/factory.py | 148 + .../providers/runtime/model_cache.py | 71 + .../providers/runtime/runtime.py | 48 + .../providers/runtime/validation.py | 122 + .../providers/stream_recovery.py | 222 ++ src/free_claude_code/runtime/__init__.py | 1 + src/free_claude_code/runtime/application.py | 481 ++++ src/free_claude_code/runtime/asgi.py | 64 + src/free_claude_code/runtime/bootstrap.py | 53 + .../runtime/provider_manager.py | 399 +++ tests/__init__.py | 1 + tests/api/support.py | 61 + tests/api/test_admin.py | 787 ++++++ tests/api/test_api.py | 430 +++ tests/api/test_api_handlers.py | 558 ++++ tests/api/test_app_lifespan_and_errors.py | 379 +++ tests/api/test_app_version.py | 21 + tests/api/test_auth.py | 122 + tests/api/test_dependencies.py | 147 + tests/api/test_detection.py | 122 + tests/api/test_execution_failure_contract.py | 421 +++ tests/api/test_model_listing.py | 144 + tests/api/test_openai_responses.py | 964 +++++++ tests/api/test_optimization_handlers.py | 235 ++ tests/api/test_ordinary_error_phases.py | 255 ++ tests/api/test_request_ids.py | 140 + tests/api/test_request_utils.py | 641 +++++ ...request_utils_filepaths_and_suggestions.py | 130 + tests/api/test_response_streams.py | 647 +++++ tests/api/test_routes_optimizations.py | 186 ++ tests/api/test_runtime_safe_logging.py | 64 + tests/api/test_safe_logging.py | 216 ++ tests/api/test_validation_log.py | 33 + tests/api/test_web_server_tools.py | 770 +++++ tests/application/test_execution.py | 181 ++ tests/application/test_routing.py | 235 ++ tests/cli/test_cli.py | 768 +++++ tests/cli/test_cli_manager_edge_cases.py | 126 + tests/cli/test_cli_ownership.py | 17 + tests/cli/test_codex_model_catalog.py | 184 ++ tests/cli/test_entrypoints.py | 1002 +++++++ tests/cli/test_managed_claude.py | 213 ++ tests/cli/test_managed_session_shutdown.py | 471 ++++ tests/cli/test_process_registry.py | 92 + tests/config/test_config.py | 1041 +++++++ tests/config/test_env_migrations.py | 92 + tests/config/test_logging_config.py | 103 + tests/config/test_provider_catalog.py | 34 + tests/conftest.py | 219 ++ .../contracts/test_admin_provider_manifest.py | 119 + .../contracts/test_architecture_contracts.py | 64 + tests/contracts/test_capability_map.py | 57 + tests/contracts/test_feature_manifest.py | 130 + tests/contracts/test_import_boundaries.py | 740 +++++ tests/contracts/test_local_provider_smoke.py | 96 + tests/contracts/test_nvidia_nim_cli_matrix.py | 555 ++++ .../contracts/test_provider_catalog_order.py | 40 + tests/contracts/test_smoke_child_process.py | 116 + tests/contracts/test_smoke_config.py | 582 ++++ tests/contracts/test_smoke_tiers.py | 88 + tests/contracts/test_stream_contracts.py | 194 ++ tests/core/anthropic/test_errors.py | 43 + tests/core/anthropic/test_models.py | 331 +++ .../anthropic/test_request_serialization.py | 104 + tests/core/anthropic/test_response_models.py | 201 ++ tests/core/anthropic/test_stream_ledger.py | 126 + tests/core/anthropic/test_stream_repair.py | 47 + .../core/openai_responses/test_conversion.py | 629 +++++ .../test_openai_responses_models.py | 94 + tests/core/openai_responses/test_sse.py | 970 +++++++ tests/core/test_async_iterators.py | 54 + tests/core/test_diagnostics.py | 152 + tests/core/test_failure_protocol_mapping.py | 49 + tests/core/test_failures.py | 112 + tests/core/test_protocol_model_ownership.py | 76 + tests/core/test_strict_sliding_window.py | 76 + tests/core/test_trace.py | 174 ++ tests/core/test_version.py | 42 + tests/messaging/test_discord_markdown.py | 203 ++ tests/messaging/test_discord_platform.py | 598 ++++ tests/messaging/test_event_parser.py | 197 ++ tests/messaging/test_extract_text.py | 109 + tests/messaging/test_handler.py | 2499 ++++++++++++++++ .../test_handler_context_isolation.py | 93 + tests/messaging/test_handler_format.py | 131 + tests/messaging/test_handler_integration.py | 163 ++ .../test_handler_markdown_and_status_edges.py | 544 ++++ tests/messaging/test_limiter.py | 431 +++ .../test_message_tree_transitions.py | 258 ++ tests/messaging/test_messaging.py | 241 ++ tests/messaging/test_messaging_factory.py | 191 ++ tests/messaging/test_platform_outbox.py | 186 ++ tests/messaging/test_platform_voice_flow.py | 927 ++++++ tests/messaging/test_reliability.py | 146 + tests/messaging/test_rendering_profiles.py | 17 + tests/messaging/test_restart_reply_restore.py | 312 ++ tests/messaging/test_robust_formatting.py | 96 + .../test_session_store_edge_cases.py | 680 +++++ .../test_stream_transcript_contract.py | 60 + tests/messaging/test_telegram.py | 312 ++ tests/messaging/test_telegram_edge_cases.py | 539 ++++ tests/messaging/test_transcript.py | 361 +++ tests/messaging/test_transcription.py | 262 ++ tests/messaging/test_transcription_nim.py | 244 ++ tests/messaging/test_tree_concurrency.py | 308 ++ .../test_tree_customer_regressions.py | 113 + .../test_tree_ownership_concurrency.py | 610 ++++ tests/messaging/test_tree_processor.py | 438 +++ tests/messaging/test_tree_queue.py | 187 ++ tests/messaging/test_tree_repository.py | 194 ++ tests/messaging/test_voice_handlers.py | 205 ++ tests/messaging/test_voice_services.py | 653 +++++ tests/provider_request_mocks.py | 25 + tests/providers/request_factory.py | 25 + tests/providers/support.py | 76 + tests/providers/test_cerebras.py | 197 ++ tests/providers/test_cloudflare.py | 312 ++ tests/providers/test_codestral.py | 156 + tests/providers/test_cohere.py | 291 ++ tests/providers/test_converter.py | 1046 +++++++ tests/providers/test_deepseek.py | 1159 ++++++++ .../test_execution_failure_boundary.py | 264 ++ tests/providers/test_failure_policy.py | 375 +++ tests/providers/test_fireworks.py | 133 + tests/providers/test_gemini.py | 430 +++ tests/providers/test_github_models.py | 321 +++ tests/providers/test_groq.py | 212 ++ tests/providers/test_huggingface.py | 215 ++ tests/providers/test_kimi.py | 109 + tests/providers/test_llamacpp.py | 146 + tests/providers/test_lmstudio.py | 242 ++ tests/providers/test_minimax.py | 168 ++ tests/providers/test_mistral.py | 764 +++++ tests/providers/test_model_validation.py | 540 ++++ tests/providers/test_nim_request_clone.py | 42 + tests/providers/test_nvidia_nim.py | 933 ++++++ .../test_nvidia_nim_degraded_retry.py | 287 ++ tests/providers/test_nvidia_nim_request.py | 541 ++++ tests/providers/test_ollama.py | 91 + tests/providers/test_open_router.py | 225 ++ .../providers/test_openai_chat_output_cap.py | 191 ++ tests/providers/test_openai_chat_usage.py | 214 ++ .../providers/test_openai_compat_5xx_retry.py | 208 ++ tests/providers/test_opencode.py | 40 + tests/providers/test_parsers.py | 514 ++++ tests/providers/test_preflight_contract.py | 62 + tests/providers/test_provider_rate_limit.py | 753 +++++ tests/providers/test_provider_runtime.py | 534 ++++ .../test_provider_transport_logging.py | 104 + tests/providers/test_sambanova.py | 195 ++ tests/providers/test_stream_recovery.py | 259 ++ tests/providers/test_streaming_errors.py | 1486 ++++++++++ tests/providers/test_subagent_interception.py | 67 + tests/providers/test_vercel.py | 166 ++ tests/providers/test_wafer.py | 158 ++ tests/providers/test_zai.py | 123 + tests/runtime/test_application_runtime.py | 937 ++++++ tests/runtime/test_provider_manager.py | 635 +++++ tests/scripts/test_ci_scripts.py | 307 ++ tests/scripts/test_installers.py | 986 +++++++ tests/scripts/test_uninstallers.py | 509 ++++ uv.lock | 2505 +++++++++++++++++ 442 files changed, 94651 insertions(+) create mode 100644 .env.example create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 AGENTS.md create mode 100644 ARCHITECTURE.md create mode 100644 CLAUDE.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 assets/admin-messaging.png create mode 100644 assets/admin-page.png create mode 100644 assets/cc-model-picker.png create mode 100644 assets/codex-model-picker.png create mode 100644 assets/codex.png create mode 100644 assets/how-it-works.mmd create mode 100644 assets/how-it-works.svg create mode 100644 assets/pic.png create mode 100644 pyproject.toml create mode 100644 scripts/ci.ps1 create mode 100755 scripts/ci.sh create mode 100644 scripts/install.ps1 create mode 100644 scripts/install.sh create mode 100644 scripts/uninstall.ps1 create mode 100644 scripts/uninstall.sh create mode 100644 smoke/README.md create mode 100644 smoke/__init__.py create mode 100644 smoke/capabilities.py create mode 100644 smoke/conftest.py create mode 100644 smoke/features.py create mode 100644 smoke/lib/__init__.py create mode 100644 smoke/lib/child_process.py create mode 100644 smoke/lib/claude_cli_matrix.py create mode 100644 smoke/lib/config.py create mode 100644 smoke/lib/e2e.py create mode 100644 smoke/lib/http.py create mode 100644 smoke/lib/local_providers.py create mode 100644 smoke/lib/report.py create mode 100644 smoke/lib/report_summary.py create mode 100644 smoke/lib/server.py create mode 100644 smoke/lib/skips.py create mode 100644 smoke/prereq/__init__.py create mode 100644 smoke/prereq/test_api_prereq_live.py create mode 100644 smoke/prereq/test_auth_prereq_live.py create mode 100644 smoke/prereq/test_cli_prereq_live.py create mode 100644 smoke/prereq/test_client_shapes_prereq_live.py create mode 100644 smoke/prereq/test_local_provider_endpoints_prereq_live.py create mode 100644 smoke/prereq/test_messaging_prereq_live.py create mode 100644 smoke/prereq/test_provider_prereq_live.py create mode 100644 smoke/prereq/test_tools_prereq_live.py create mode 100644 smoke/prereq/test_voice_prereq_live.py create mode 100644 smoke/product/__init__.py create mode 100644 smoke/product/test_api_product_live.py create mode 100644 smoke/product/test_auth_product_live.py create mode 100644 smoke/product/test_cli_package_product_live.py create mode 100644 smoke/product/test_client_product_live.py create mode 100644 smoke/product/test_config_extensibility_product_live.py create mode 100644 smoke/product/test_live_platform_product_live.py create mode 100644 smoke/product/test_local_provider_product_live.py create mode 100644 smoke/product/test_messaging_product_live.py create mode 100644 smoke/product/test_nvidia_nim_cli_product_live.py create mode 100644 smoke/product/test_openrouter_free_cli_product_live.py create mode 100644 smoke/product/test_provider_product_live.py create mode 100644 smoke/product/test_runtime_ownership_product_live.py create mode 100644 smoke/product/test_voice_product_live.py create mode 100644 src/free_claude_code/__init__.py create mode 100644 src/free_claude_code/api/__init__.py create mode 100644 src/free_claude_code/api/admin_routes.py create mode 100644 src/free_claude_code/api/admin_static/admin.css create mode 100644 src/free_claude_code/api/admin_static/admin.js create mode 100644 src/free_claude_code/api/admin_static/index.html create mode 100644 src/free_claude_code/api/app.py create mode 100644 src/free_claude_code/api/command_utils.py create mode 100644 src/free_claude_code/api/dependencies.py create mode 100644 src/free_claude_code/api/detection.py create mode 100644 src/free_claude_code/api/handlers/__init__.py create mode 100644 src/free_claude_code/api/handlers/messages.py create mode 100644 src/free_claude_code/api/handlers/responses.py create mode 100644 src/free_claude_code/api/handlers/token_count.py create mode 100644 src/free_claude_code/api/model_catalog.py create mode 100644 src/free_claude_code/api/optimization_handlers.py create mode 100644 src/free_claude_code/api/ports.py create mode 100644 src/free_claude_code/api/request_errors.py create mode 100644 src/free_claude_code/api/request_ids.py create mode 100644 src/free_claude_code/api/response_streams.py create mode 100644 src/free_claude_code/api/routes.py create mode 100644 src/free_claude_code/api/validation_log.py create mode 100644 src/free_claude_code/api/web_tools/__init__.py create mode 100644 src/free_claude_code/api/web_tools/constants.py create mode 100644 src/free_claude_code/api/web_tools/egress.py create mode 100644 src/free_claude_code/api/web_tools/outbound.py create mode 100644 src/free_claude_code/api/web_tools/parsers.py create mode 100644 src/free_claude_code/api/web_tools/request.py create mode 100644 src/free_claude_code/api/web_tools/streaming.py create mode 100644 src/free_claude_code/application/__init__.py create mode 100644 src/free_claude_code/application/errors.py create mode 100644 src/free_claude_code/application/execution.py create mode 100644 src/free_claude_code/application/model_metadata.py create mode 100644 src/free_claude_code/application/ports.py create mode 100644 src/free_claude_code/application/routing.py create mode 100644 src/free_claude_code/cli/__init__.py create mode 100644 src/free_claude_code/cli/claude_env.py create mode 100644 src/free_claude_code/cli/entrypoints.py create mode 100644 src/free_claude_code/cli/launchers/__init__.py create mode 100644 src/free_claude_code/cli/launchers/claude.py create mode 100644 src/free_claude_code/cli/launchers/codex.py create mode 100644 src/free_claude_code/cli/launchers/codex_model_catalog.py create mode 100644 src/free_claude_code/cli/launchers/common.py create mode 100644 src/free_claude_code/cli/launchers/pi.py create mode 100644 src/free_claude_code/cli/launchers/pi_extension.ts create mode 100644 src/free_claude_code/cli/managed/__init__.py create mode 100644 src/free_claude_code/cli/managed/claude.py create mode 100644 src/free_claude_code/cli/managed/diagnostics.py create mode 100644 src/free_claude_code/cli/managed/manager.py create mode 100644 src/free_claude_code/cli/managed/session.py create mode 100644 src/free_claude_code/cli/process_registry.py create mode 100644 src/free_claude_code/cli/proxy_auth.py create mode 100644 src/free_claude_code/config/__init__.py create mode 100644 src/free_claude_code/config/admin/__init__.py create mode 100644 src/free_claude_code/config/admin/manifest.py create mode 100644 src/free_claude_code/config/admin/persistence.py create mode 100644 src/free_claude_code/config/admin/provider_manifest.py create mode 100644 src/free_claude_code/config/admin/sources.py create mode 100644 src/free_claude_code/config/admin/status.py create mode 100644 src/free_claude_code/config/admin/validation.py create mode 100644 src/free_claude_code/config/admin/values.py create mode 100644 src/free_claude_code/config/constants.py create mode 100644 src/free_claude_code/config/env_files.py create mode 100644 src/free_claude_code/config/env_migrations.py create mode 100644 src/free_claude_code/config/env_template.py create mode 100644 src/free_claude_code/config/logging_config.py create mode 100644 src/free_claude_code/config/model_refs.py create mode 100644 src/free_claude_code/config/nim.py create mode 100644 src/free_claude_code/config/paths.py create mode 100644 src/free_claude_code/config/provider_catalog.py create mode 100644 src/free_claude_code/config/server_urls.py create mode 100644 src/free_claude_code/config/settings.py create mode 100644 src/free_claude_code/core/__init__.py create mode 100644 src/free_claude_code/core/anthropic/__init__.py create mode 100644 src/free_claude_code/core/anthropic/content.py create mode 100644 src/free_claude_code/core/anthropic/conversion.py create mode 100644 src/free_claude_code/core/anthropic/errors.py create mode 100644 src/free_claude_code/core/anthropic/models.py create mode 100644 src/free_claude_code/core/anthropic/request_serialization.py create mode 100644 src/free_claude_code/core/anthropic/request_snapshot.py create mode 100644 src/free_claude_code/core/anthropic/server_tool_sse.py create mode 100644 src/free_claude_code/core/anthropic/sse_aggregation.py create mode 100644 src/free_claude_code/core/anthropic/stream_contracts.py create mode 100644 src/free_claude_code/core/anthropic/streaming/__init__.py create mode 100644 src/free_claude_code/core/anthropic/streaming/emitter.py create mode 100644 src/free_claude_code/core/anthropic/streaming/ledger.py create mode 100644 src/free_claude_code/core/anthropic/streaming/recovery.py create mode 100644 src/free_claude_code/core/anthropic/thinking.py create mode 100644 src/free_claude_code/core/anthropic/tokens.py create mode 100644 src/free_claude_code/core/anthropic/tools.py create mode 100644 src/free_claude_code/core/anthropic/utils.py create mode 100644 src/free_claude_code/core/async_iterators.py create mode 100644 src/free_claude_code/core/diagnostics.py create mode 100644 src/free_claude_code/core/failures.py create mode 100644 src/free_claude_code/core/gateway_model_ids.py create mode 100644 src/free_claude_code/core/openai_responses/__init__.py create mode 100644 src/free_claude_code/core/openai_responses/adapter.py create mode 100644 src/free_claude_code/core/openai_responses/anthropic_sse.py create mode 100644 src/free_claude_code/core/openai_responses/errors.py create mode 100644 src/free_claude_code/core/openai_responses/events.py create mode 100644 src/free_claude_code/core/openai_responses/ids.py create mode 100644 src/free_claude_code/core/openai_responses/input.py create mode 100644 src/free_claude_code/core/openai_responses/items.py create mode 100644 src/free_claude_code/core/openai_responses/models.py create mode 100644 src/free_claude_code/core/openai_responses/reasoning.py create mode 100644 src/free_claude_code/core/openai_responses/stream.py create mode 100644 src/free_claude_code/core/openai_responses/streaming/__init__.py create mode 100644 src/free_claude_code/core/openai_responses/streaming/assembler.py create mode 100644 src/free_claude_code/core/openai_responses/streaming/blocks.py create mode 100644 src/free_claude_code/core/openai_responses/streaming/completion.py create mode 100644 src/free_claude_code/core/openai_responses/streaming/error_mapping.py create mode 100644 src/free_claude_code/core/openai_responses/streaming/event_builders.py create mode 100644 src/free_claude_code/core/openai_responses/streaming/ledger.py create mode 100644 src/free_claude_code/core/openai_responses/tools.py create mode 100644 src/free_claude_code/core/openai_responses/usage.py create mode 100644 src/free_claude_code/core/rate_limit.py create mode 100644 src/free_claude_code/core/trace.py create mode 100644 src/free_claude_code/core/version.py create mode 100644 src/free_claude_code/messaging/__init__.py create mode 100644 src/free_claude_code/messaging/cli_event_constants.py create mode 100644 src/free_claude_code/messaging/command_context.py create mode 100644 src/free_claude_code/messaging/command_dispatcher.py create mode 100644 src/free_claude_code/messaging/commands.py create mode 100644 src/free_claude_code/messaging/event_parser.py create mode 100644 src/free_claude_code/messaging/limiter.py create mode 100644 src/free_claude_code/messaging/managed_protocols.py create mode 100644 src/free_claude_code/messaging/models.py create mode 100644 src/free_claude_code/messaging/node_event_pipeline.py create mode 100644 src/free_claude_code/messaging/node_runner.py create mode 100644 src/free_claude_code/messaging/platforms/__init__.py create mode 100644 src/free_claude_code/messaging/platforms/discord.py create mode 100644 src/free_claude_code/messaging/platforms/discord_inbound.py create mode 100644 src/free_claude_code/messaging/platforms/discord_io.py create mode 100644 src/free_claude_code/messaging/platforms/factory.py create mode 100644 src/free_claude_code/messaging/platforms/outbox.py create mode 100644 src/free_claude_code/messaging/platforms/ports.py create mode 100644 src/free_claude_code/messaging/platforms/telegram.py create mode 100644 src/free_claude_code/messaging/platforms/telegram_inbound.py create mode 100644 src/free_claude_code/messaging/platforms/telegram_io.py create mode 100644 src/free_claude_code/messaging/platforms/voice_flow.py create mode 100644 src/free_claude_code/messaging/rendering/__init__.py create mode 100644 src/free_claude_code/messaging/rendering/discord_markdown.py create mode 100644 src/free_claude_code/messaging/rendering/markdown_tables.py create mode 100644 src/free_claude_code/messaging/rendering/profiles.py create mode 100644 src/free_claude_code/messaging/rendering/telegram_markdown.py create mode 100644 src/free_claude_code/messaging/safe_diagnostics.py create mode 100644 src/free_claude_code/messaging/session/__init__.py create mode 100644 src/free_claude_code/messaging/session/managed_message_log.py create mode 100644 src/free_claude_code/messaging/session/persistence.py create mode 100644 src/free_claude_code/messaging/session/store.py create mode 100644 src/free_claude_code/messaging/transcript/__init__.py create mode 100644 src/free_claude_code/messaging/transcript/buffer.py create mode 100644 src/free_claude_code/messaging/transcript/context.py create mode 100644 src/free_claude_code/messaging/transcript/renderer.py create mode 100644 src/free_claude_code/messaging/transcript/segments.py create mode 100644 src/free_claude_code/messaging/transcript/subagents.py create mode 100644 src/free_claude_code/messaging/transcription.py create mode 100644 src/free_claude_code/messaging/trees/__init__.py create mode 100644 src/free_claude_code/messaging/trees/graph.py create mode 100644 src/free_claude_code/messaging/trees/identity.py create mode 100644 src/free_claude_code/messaging/trees/manager.py create mode 100644 src/free_claude_code/messaging/trees/node.py create mode 100644 src/free_claude_code/messaging/trees/processor.py create mode 100644 src/free_claude_code/messaging/trees/queue.py create mode 100644 src/free_claude_code/messaging/trees/repository.py create mode 100644 src/free_claude_code/messaging/trees/runtime.py create mode 100644 src/free_claude_code/messaging/trees/snapshot.py create mode 100644 src/free_claude_code/messaging/trees/transitions.py create mode 100644 src/free_claude_code/messaging/turn_intake.py create mode 100644 src/free_claude_code/messaging/ui_updates.py create mode 100644 src/free_claude_code/messaging/voice.py create mode 100644 src/free_claude_code/messaging/workflow.py create mode 100644 src/free_claude_code/providers/__init__.py create mode 100644 src/free_claude_code/providers/base.py create mode 100644 src/free_claude_code/providers/cloudflare/__init__.py create mode 100644 src/free_claude_code/providers/cloudflare/client.py create mode 100644 src/free_claude_code/providers/deepseek/__init__.py create mode 100644 src/free_claude_code/providers/deepseek/client.py create mode 100644 src/free_claude_code/providers/deepseek/compat.py create mode 100644 src/free_claude_code/providers/failure_policy.py create mode 100644 src/free_claude_code/providers/gemini/__init__.py create mode 100644 src/free_claude_code/providers/gemini/client.py create mode 100644 src/free_claude_code/providers/gemini/quirks.py create mode 100644 src/free_claude_code/providers/github_models/__init__.py create mode 100644 src/free_claude_code/providers/github_models/client.py create mode 100644 src/free_claude_code/providers/http.py create mode 100644 src/free_claude_code/providers/lmstudio/__init__.py create mode 100644 src/free_claude_code/providers/lmstudio/client.py create mode 100644 src/free_claude_code/providers/mistral/__init__.py create mode 100644 src/free_claude_code/providers/mistral/client.py create mode 100644 src/free_claude_code/providers/mistral/reasoning.py create mode 100644 src/free_claude_code/providers/model_listing.py create mode 100644 src/free_claude_code/providers/nvidia_nim/__init__.py create mode 100644 src/free_claude_code/providers/nvidia_nim/client.py create mode 100644 src/free_claude_code/providers/nvidia_nim/request_options.py create mode 100644 src/free_claude_code/providers/nvidia_nim/retry.py create mode 100644 src/free_claude_code/providers/nvidia_nim/tool_schema.py create mode 100644 src/free_claude_code/providers/nvidia_nim/voice.py create mode 100644 src/free_claude_code/providers/open_router/__init__.py create mode 100644 src/free_claude_code/providers/open_router/client.py create mode 100644 src/free_claude_code/providers/openai_chat/__init__.py create mode 100644 src/free_claude_code/providers/openai_chat/base_url.py create mode 100644 src/free_claude_code/providers/openai_chat/extra_body.py create mode 100644 src/free_claude_code/providers/openai_chat/output_cap.py create mode 100644 src/free_claude_code/providers/openai_chat/profiles.py create mode 100644 src/free_claude_code/providers/openai_chat/provider.py create mode 100644 src/free_claude_code/providers/openai_chat/request_policy.py create mode 100644 src/free_claude_code/providers/openai_chat/tool_calls.py create mode 100644 src/free_claude_code/providers/openai_chat/usage.py create mode 100644 src/free_claude_code/providers/rate_limit.py create mode 100644 src/free_claude_code/providers/runtime/__init__.py create mode 100644 src/free_claude_code/providers/runtime/config.py create mode 100644 src/free_claude_code/providers/runtime/discovery.py create mode 100644 src/free_claude_code/providers/runtime/factory.py create mode 100644 src/free_claude_code/providers/runtime/model_cache.py create mode 100644 src/free_claude_code/providers/runtime/runtime.py create mode 100644 src/free_claude_code/providers/runtime/validation.py create mode 100644 src/free_claude_code/providers/stream_recovery.py create mode 100644 src/free_claude_code/runtime/__init__.py create mode 100644 src/free_claude_code/runtime/application.py create mode 100644 src/free_claude_code/runtime/asgi.py create mode 100644 src/free_claude_code/runtime/bootstrap.py create mode 100644 src/free_claude_code/runtime/provider_manager.py create mode 100644 tests/__init__.py create mode 100644 tests/api/support.py create mode 100644 tests/api/test_admin.py create mode 100644 tests/api/test_api.py create mode 100644 tests/api/test_api_handlers.py create mode 100644 tests/api/test_app_lifespan_and_errors.py create mode 100644 tests/api/test_app_version.py create mode 100644 tests/api/test_auth.py create mode 100644 tests/api/test_dependencies.py create mode 100644 tests/api/test_detection.py create mode 100644 tests/api/test_execution_failure_contract.py create mode 100644 tests/api/test_model_listing.py create mode 100644 tests/api/test_openai_responses.py create mode 100644 tests/api/test_optimization_handlers.py create mode 100644 tests/api/test_ordinary_error_phases.py create mode 100644 tests/api/test_request_ids.py create mode 100644 tests/api/test_request_utils.py create mode 100644 tests/api/test_request_utils_filepaths_and_suggestions.py create mode 100644 tests/api/test_response_streams.py create mode 100644 tests/api/test_routes_optimizations.py create mode 100644 tests/api/test_runtime_safe_logging.py create mode 100644 tests/api/test_safe_logging.py create mode 100644 tests/api/test_validation_log.py create mode 100644 tests/api/test_web_server_tools.py create mode 100644 tests/application/test_execution.py create mode 100644 tests/application/test_routing.py create mode 100644 tests/cli/test_cli.py create mode 100644 tests/cli/test_cli_manager_edge_cases.py create mode 100644 tests/cli/test_cli_ownership.py create mode 100644 tests/cli/test_codex_model_catalog.py create mode 100644 tests/cli/test_entrypoints.py create mode 100644 tests/cli/test_managed_claude.py create mode 100644 tests/cli/test_managed_session_shutdown.py create mode 100644 tests/cli/test_process_registry.py create mode 100644 tests/config/test_config.py create mode 100644 tests/config/test_env_migrations.py create mode 100644 tests/config/test_logging_config.py create mode 100644 tests/config/test_provider_catalog.py create mode 100644 tests/conftest.py create mode 100644 tests/contracts/test_admin_provider_manifest.py create mode 100644 tests/contracts/test_architecture_contracts.py create mode 100644 tests/contracts/test_capability_map.py create mode 100644 tests/contracts/test_feature_manifest.py create mode 100644 tests/contracts/test_import_boundaries.py create mode 100644 tests/contracts/test_local_provider_smoke.py create mode 100644 tests/contracts/test_nvidia_nim_cli_matrix.py create mode 100644 tests/contracts/test_provider_catalog_order.py create mode 100644 tests/contracts/test_smoke_child_process.py create mode 100644 tests/contracts/test_smoke_config.py create mode 100644 tests/contracts/test_smoke_tiers.py create mode 100644 tests/contracts/test_stream_contracts.py create mode 100644 tests/core/anthropic/test_errors.py create mode 100644 tests/core/anthropic/test_models.py create mode 100644 tests/core/anthropic/test_request_serialization.py create mode 100644 tests/core/anthropic/test_response_models.py create mode 100644 tests/core/anthropic/test_stream_ledger.py create mode 100644 tests/core/anthropic/test_stream_repair.py create mode 100644 tests/core/openai_responses/test_conversion.py create mode 100644 tests/core/openai_responses/test_openai_responses_models.py create mode 100644 tests/core/openai_responses/test_sse.py create mode 100644 tests/core/test_async_iterators.py create mode 100644 tests/core/test_diagnostics.py create mode 100644 tests/core/test_failure_protocol_mapping.py create mode 100644 tests/core/test_failures.py create mode 100644 tests/core/test_protocol_model_ownership.py create mode 100644 tests/core/test_strict_sliding_window.py create mode 100644 tests/core/test_trace.py create mode 100644 tests/core/test_version.py create mode 100644 tests/messaging/test_discord_markdown.py create mode 100644 tests/messaging/test_discord_platform.py create mode 100644 tests/messaging/test_event_parser.py create mode 100644 tests/messaging/test_extract_text.py create mode 100644 tests/messaging/test_handler.py create mode 100644 tests/messaging/test_handler_context_isolation.py create mode 100644 tests/messaging/test_handler_format.py create mode 100644 tests/messaging/test_handler_integration.py create mode 100644 tests/messaging/test_handler_markdown_and_status_edges.py create mode 100644 tests/messaging/test_limiter.py create mode 100644 tests/messaging/test_message_tree_transitions.py create mode 100644 tests/messaging/test_messaging.py create mode 100644 tests/messaging/test_messaging_factory.py create mode 100644 tests/messaging/test_platform_outbox.py create mode 100644 tests/messaging/test_platform_voice_flow.py create mode 100644 tests/messaging/test_reliability.py create mode 100644 tests/messaging/test_rendering_profiles.py create mode 100644 tests/messaging/test_restart_reply_restore.py create mode 100644 tests/messaging/test_robust_formatting.py create mode 100644 tests/messaging/test_session_store_edge_cases.py create mode 100644 tests/messaging/test_stream_transcript_contract.py create mode 100644 tests/messaging/test_telegram.py create mode 100644 tests/messaging/test_telegram_edge_cases.py create mode 100644 tests/messaging/test_transcript.py create mode 100644 tests/messaging/test_transcription.py create mode 100644 tests/messaging/test_transcription_nim.py create mode 100644 tests/messaging/test_tree_concurrency.py create mode 100644 tests/messaging/test_tree_customer_regressions.py create mode 100644 tests/messaging/test_tree_ownership_concurrency.py create mode 100644 tests/messaging/test_tree_processor.py create mode 100644 tests/messaging/test_tree_queue.py create mode 100644 tests/messaging/test_tree_repository.py create mode 100644 tests/messaging/test_voice_handlers.py create mode 100644 tests/messaging/test_voice_services.py create mode 100644 tests/provider_request_mocks.py create mode 100644 tests/providers/request_factory.py create mode 100644 tests/providers/support.py create mode 100644 tests/providers/test_cerebras.py create mode 100644 tests/providers/test_cloudflare.py create mode 100644 tests/providers/test_codestral.py create mode 100644 tests/providers/test_cohere.py create mode 100644 tests/providers/test_converter.py create mode 100644 tests/providers/test_deepseek.py create mode 100644 tests/providers/test_execution_failure_boundary.py create mode 100644 tests/providers/test_failure_policy.py create mode 100644 tests/providers/test_fireworks.py create mode 100644 tests/providers/test_gemini.py create mode 100644 tests/providers/test_github_models.py create mode 100644 tests/providers/test_groq.py create mode 100644 tests/providers/test_huggingface.py create mode 100644 tests/providers/test_kimi.py create mode 100644 tests/providers/test_llamacpp.py create mode 100644 tests/providers/test_lmstudio.py create mode 100644 tests/providers/test_minimax.py create mode 100644 tests/providers/test_mistral.py create mode 100644 tests/providers/test_model_validation.py create mode 100644 tests/providers/test_nim_request_clone.py create mode 100644 tests/providers/test_nvidia_nim.py create mode 100644 tests/providers/test_nvidia_nim_degraded_retry.py create mode 100644 tests/providers/test_nvidia_nim_request.py create mode 100644 tests/providers/test_ollama.py create mode 100644 tests/providers/test_open_router.py create mode 100644 tests/providers/test_openai_chat_output_cap.py create mode 100644 tests/providers/test_openai_chat_usage.py create mode 100644 tests/providers/test_openai_compat_5xx_retry.py create mode 100644 tests/providers/test_opencode.py create mode 100644 tests/providers/test_parsers.py create mode 100644 tests/providers/test_preflight_contract.py create mode 100644 tests/providers/test_provider_rate_limit.py create mode 100644 tests/providers/test_provider_runtime.py create mode 100644 tests/providers/test_provider_transport_logging.py create mode 100644 tests/providers/test_sambanova.py create mode 100644 tests/providers/test_stream_recovery.py create mode 100644 tests/providers/test_streaming_errors.py create mode 100644 tests/providers/test_subagent_interception.py create mode 100644 tests/providers/test_vercel.py create mode 100644 tests/providers/test_wafer.py create mode 100644 tests/providers/test_zai.py create mode 100644 tests/runtime/test_application_runtime.py create mode 100644 tests/runtime/test_provider_manager.py create mode 100644 tests/scripts/test_ci_scripts.py create mode 100644 tests/scripts/test_installers.py create mode 100644 tests/scripts/test_uninstallers.py create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7da5761 --- /dev/null +++ b/.env.example @@ -0,0 +1,256 @@ +# NVIDIA NIM Config +NVIDIA_NIM_API_KEY="" + + +# OpenRouter Config +OPENROUTER_API_KEY="" + + +# Mistral La Plateforme Config (Experiment plan free tier – rate limits; OpenAI-compatible at api.mistral.ai/v1) +MISTRAL_API_KEY="" + + +# Mistral Codestral (separate key from La Plateforme; OpenAI-compatible at codestral.mistral.ai/v1) +CODESTRAL_API_KEY="" + + +# DeepSeek Config (OpenAI-compatible Chat Completions at api.deepseek.com) +DEEPSEEK_API_KEY="" + + +# Kimi Config (OpenAI-compatible Chat Completions at api.moonshot.ai/v1) +KIMI_API_KEY="" + + +# Wafer Config (OpenAI-compatible Chat Completions at pass.wafer.ai/v1) +WAFER_API_KEY="" + + +# MiniMax Config (OpenAI-compatible Chat Completions at api.minimax.io/v1) +MINIMAX_API_KEY="" + + +# OpenCode Zen (opencode.ai/zen/v1) and OpenCode Go (opencode.ai/zen/go/v1) share OPENCODE_API_KEY +OPENCODE_API_KEY="" + + +# Vercel AI Gateway Config (OpenAI-compatible Chat Completions at ai-gateway.vercel.sh/v1) +AI_GATEWAY_API_KEY="" + + +# Hugging Face Inference Providers Config (OpenAI-compatible Chat Completions at router.huggingface.co/v1) +HUGGINGFACE_API_KEY="" + + +# Cohere Config (OpenAI-compatible Chat Completions at api.cohere.ai/compatibility/v1) +COHERE_API_KEY="" + + +# GitHub Models Config (OpenAI-compatible Chat Completions at models.github.ai/inference) +GITHUB_MODELS_TOKEN="" + + +# Z.ai Config (GLM Coding Plan OpenAI-compatible Chat Completions at api.z.ai/api/coding/paas/v4) +ZAI_API_KEY="" + + +# Fireworks AI Config (OpenAI-compatible Chat Completions at api.fireworks.ai/inference/v1) +FIREWORKS_API_KEY="" + + +# Cloudflare Workers AI Config (OpenAI-compatible Chat Completions at api.cloudflare.com/client/v4/accounts//ai/v1) +CLOUDFLARE_API_TOKEN="" +CLOUDFLARE_ACCOUNT_ID="" + + +# Gemini / Google AI Studio (OpenAI-compatible Chat Completions; see https://ai.google.dev/gemini-api/docs/openai) +GEMINI_API_KEY="" + + +# Groq Cloud (OpenAI-compatible Chat Completions; see https://console.groq.com/docs/openai) +GROQ_API_KEY="" + + +# SambaNova Cloud (OpenAI-compatible Chat Completions at api.sambanova.ai/v1) +SAMBANOVA_API_KEY="" + + +# Cerebras Inference (OpenAI-compatible Chat Completions; see https://inference-docs.cerebras.ai/resources/openai) +CEREBRAS_API_KEY="" + + +# LM Studio Config (local provider, no API key required) +LM_STUDIO_BASE_URL="http://localhost:1234/v1" + + +# Llama.cpp Config (local provider, no API key required) +LLAMACPP_BASE_URL="http://localhost:8080/v1" + + +# Ollama Config (local provider, no API key required) +OLLAMA_BASE_URL="http://localhost:11434" + + +# All Claude model requests are mapped to these models, plain model is fallback +# Format: provider_type/model/name +# Valid providers: "nvidia_nim" | "open_router" | "gemini" | "deepseek" | "mistral" | "mistral_codestral" | "opencode" | "opencode_go" | "vercel" | "huggingface" | "cohere" | "github_models" | "wafer" | "kimi" | "minimax" | "cerebras" | "groq" | "sambanova" | "fireworks" | "cloudflare" | "zai" | "lmstudio" | "llamacpp" | "ollama" +MODEL_OPUS= +MODEL_SONNET= +MODEL_HAIKU= +MODEL="nvidia_nim/nvidia/nemotron-3-super-120b-a12b" + + +# Optional live smoke model overrides. Provider smoke runs once per configured +# provider even when MODEL/MODEL_* route to a different provider. +FCC_SMOKE_MODEL_NVIDIA_NIM= +FCC_SMOKE_MODEL_OPEN_ROUTER= +FCC_SMOKE_MODEL_MISTRAL= +FCC_SMOKE_MODEL_MISTRAL_REASONING= +FCC_SMOKE_MODEL_MISTRAL_CODESTRAL= +FCC_SMOKE_MODEL_DEEPSEEK= +FCC_SMOKE_MODEL_LMSTUDIO= +FCC_SMOKE_MODEL_LLAMACPP= +FCC_SMOKE_MODEL_OLLAMA= +FCC_SMOKE_MODEL_KIMI= +FCC_SMOKE_MODEL_WAFER= +FCC_SMOKE_MODEL_MINIMAX= +FCC_SMOKE_MODEL_OPENCODE= +FCC_SMOKE_MODEL_OPENCODE_GO= +FCC_SMOKE_MODEL_VERCEL= +FCC_SMOKE_MODEL_HUGGINGFACE= +FCC_SMOKE_MODEL_COHERE= +FCC_SMOKE_MODEL_GITHUB_MODELS= +FCC_SMOKE_MODEL_ZAI= +FCC_SMOKE_MODEL_FIREWORKS= +FCC_SMOKE_MODEL_CLOUDFLARE= +FCC_SMOKE_MODEL_GEMINI= +FCC_SMOKE_MODEL_GROQ= +FCC_SMOKE_MODEL_SAMBANOVA= +FCC_SMOKE_MODEL_CEREBRAS= +FCC_SMOKE_NIM_MODELS= +FCC_SMOKE_NIM_EXTRA_MODELS= +FCC_SMOKE_OPENROUTER_FREE_MODELS= +FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS= + + +# Thinking output +# Per-Claude-model switches for provider reasoning requests and Claude thinking blocks. +# Blank per-model switches inherit ENABLE_MODEL_THINKING. +ENABLE_OPUS_THINKING= +ENABLE_SONNET_THINKING= +ENABLE_HAIKU_THINKING= +ENABLE_MODEL_THINKING=true + + +# Provider config +# Per-provider proxy support: http and socks5, example: "http://username:password@host:port" +NVIDIA_NIM_PROXY="" +OPENROUTER_PROXY="" +MISTRAL_PROXY="" +CODESTRAL_PROXY="" +LMSTUDIO_PROXY="" +LLAMACPP_PROXY="" +KIMI_PROXY="" +WAFER_PROXY="" +MINIMAX_PROXY="" +OPENCODE_PROXY="" +OPENCODE_GO_PROXY="" +VERCEL_AI_GATEWAY_PROXY="" +HUGGINGFACE_PROXY="" +COHERE_PROXY="" +GITHUB_MODELS_PROXY="" +ZAI_PROXY="" +FIREWORKS_PROXY="" +CLOUDFLARE_PROXY="" +GEMINI_PROXY="" +GROQ_PROXY="" +SAMBANOVA_PROXY="" +CEREBRAS_PROXY="" + +PROVIDER_RATE_LIMIT=1 +PROVIDER_RATE_WINDOW=3 +PROVIDER_MAX_CONCURRENCY=5 + + +# HTTP client timeouts (seconds) for provider API requests +HTTP_READ_TIMEOUT=300 +HTTP_WRITE_TIMEOUT=60 +HTTP_CONNECT_TIMEOUT=60 + + +# Optional server API key (Anthropic-style) +ANTHROPIC_AUTH_TOKEN="freecc" + + +# Open /admin in the default browser when fcc-server becomes healthy (set 0/false/no to disable) +FCC_OPEN_BROWSER=true + + +# Messaging Platform: "telegram" | "discord" | "none" +MESSAGING_PLATFORM="discord" +MESSAGING_RATE_LIMIT=1 +MESSAGING_RATE_WINDOW=1 + + +# Voice Note Transcription +VOICE_NOTE_ENABLED=false +# WHISPER_DEVICE: "cpu" | "cuda" | "nvidia_nim" +# - "cpu"/"cuda": Hugging Face transformers Whisper (offline, free; install with: uv sync --extra voice_local) +# - "nvidia_nim": NVIDIA NIM Whisper via Riva gRPC (requires NVIDIA_NIM_API_KEY; install with: uv sync --extra voice) +# (Independent of MODEL=nvidia_nim/...: that selects the *chat* provider; this selects voice STT only.) +WHISPER_DEVICE="nvidia_nim" +# WHISPER_MODEL: +# - For cpu/cuda: Hugging Face ID or short name (tiny, base, small, medium, large-v2, large-v3, large-v3-turbo) +# - For nvidia_nim: NVIDIA NIM model (e.g., "nvidia/parakeet-ctc-1.1b-asr", "openai/whisper-large-v3") +# - For nvidia_nim, default to "openai/whisper-large-v3" for best performance +WHISPER_MODEL="openai/whisper-large-v3" + + +# Telegram Config +TELEGRAM_BOT_TOKEN="" +ALLOWED_TELEGRAM_USER_ID="" +# Optional Telegram-only proxy. +# Supported schemes: http, https, socks4, socks5, socks5h. +# Example: "socks5://127.0.0.1:1080" or "https://user:password@host:port" +TELEGRAM_PROXY_URL="" + + +# Discord Config +DISCORD_BOT_TOKEN="" +ALLOWED_DISCORD_CHANNELS="" + + +# Agent Config +ALLOWED_DIR="" +FAST_PREFIX_DETECTION=true +ENABLE_NETWORK_PROBE_MOCK=true +ENABLE_TITLE_GENERATION_SKIP=true +ENABLE_SUGGESTION_MODE_SKIP=true +ENABLE_FILEPATH_EXTRACTION_MOCK=true + + +# Local Anthropic web_search / web_fetch handling (performs outbound HTTP; on by default) +ENABLE_WEB_SERVER_TOOLS=true +WEB_FETCH_ALLOWED_SCHEMES=http,https +WEB_FETCH_ALLOW_PRIVATE_NETWORKS=false + + +# Structured TRACE logs: lines with `"trace": true` merge ingress/routing/cli/provider/egress +# stages. Conversation text is logged in those payloads (verbatim). Values under keys named +# like ``api_key`` / ``authorization`` are redacted. Raw transport payloads still require +# the LOG_RAW_* toggles below. +# +# Verbose diagnostics (avoid logging raw prompts / SSE bodies in production) +DEBUG_PLATFORM_EDITS=false +DEBUG_SUBAGENT_STACK=false +# When true, also allows DEBUG-level httpx/httpcore/telegram log noise (not just payload logging). +LOG_RAW_API_PAYLOADS=false +LOG_RAW_SSE_EVENTS=false +# When true, log full exception text and tracebacks for unhandled errors (may leak request-derived data). +LOG_API_ERROR_TRACEBACKS=false +# When true, log message/transcription text previews in messaging adapters only (handler ingress always TRACEs verbatim text separately). +LOG_RAW_MESSAGING_CONTENT=false +# When true, log full Claude CLI stderr, non-JSON stdout lines, and parser error text. +LOG_RAW_CLI_DIAGNOSTICS=false +# When true, log full exception and CLI error message strings in messaging (may leak user content). +LOG_MESSAGING_ERROR_DETAILS=false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..776459c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "weekly" + groups: + minor-and-patch: + update-types: ["minor", "patch"] + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..4660574 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,74 @@ +# Branch protection: require every status check this workflow reports, for example: +# Ban suppressions and legacy annotations +# ruff-format, ruff-check, ty, pytest +# GitHub may prefix with the workflow name (e.g. "CI / ruff-format"); use the names the branch protection UI offers after a run. + +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + no-suppressions-or-legacy-annotations: + name: Ban suppressions and legacy annotations + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + fetch-depth: 1 + + - name: "Fail on type ignores and legacy future annotations" + run: | + if grep -rE '# type: ignore|# ty: ignore|from __future__ import annotations' --include='*.py' . --exclude-dir=.venv --exclude-dir=.git; then + echo "::error::type: ignore / ty: ignore comments and legacy future annotations are not allowed. Fix the underlying type/import issue instead." + exit 1 + fi + exit 0 + + quality: + name: ${{ matrix.id }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - id: ruff-format + run: uv run ruff format --check + - id: ruff-check + run: uv run ruff check + - id: ty + run: uv run ty check + - id: pytest + run: uv run pytest -v --tb=short + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + ref: ${{ github.event.pull_request.head.sha || github.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + fetch-depth: 1 + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 + with: + version: "0.11.15" + enable-cache: true + cache-python: true + + - name: Run + run: ${{ matrix.run }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18f87d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +__pycache__ +.claude +.cursor +.pytest_cache +.ruff_cache +.serena +.venv +agent_workspace +.env +/logs/ +server.log +/server.*.log +debug-*.log +.coverage +llama_cache +.smoke-results +.vscode \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..12566ed --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14.0 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..164e80d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,105 @@ +# AGENTIC DIRECTIVE + +> Keep AGENTS.md and CLAUDE.md identical. + +## CODING ENVIRONMENT + +- Install astral uv using "curl -LsSf https://astral.sh/uv/install.sh | sh" if not already installed and if already installed then update it to the latest version +- Install Python 3.14.0 stable using `uv python install 3.14.0` if not already installed (requires uv >=0.9; see `[tool.uv] required-version` in `pyproject.toml`) +- Always use `uv run` to run files instead of the global `python` command. +- Current uv ruff formatter is set to py314 which has supports multiple exception types without paranthesis (except TypeError, ValueError:) +- Read `.env.example` for environment variables. +- All CI checks must pass; failing checks block merge. +- Add tests for new changes (including edge cases). +- Before pushing, prefer `./scripts/ci.sh` (macOS/Linux) or `.\scripts\ci.ps1` (Windows) to run the local CI sequence; requires `uv` on PATH. The local scripts run Ruff in repair mode (`ruff format`, then `ruff check --fix`) before type checking and tests. +- Use `--only` / `--skip` (PowerShell: `-Only` / `-Skip`) to run a subset when iterating; use `--dry-run` to print commands without running them. +- GitHub CI remains check-only for Ruff (`ruff format --check`, `ruff check`) so branch protection verifies committed code. +- Fall back to individual repair commands when debugging local failures: `uv run ruff format`, `uv run ruff check --fix`, `uv run ty check`, `uv run pytest -v --tb=short`. Use GitHub-style checks only when verifying enforcement locally: `uv run ruff format --check`, `uv run ruff check`. +- Do not add `# type: ignore` or `# ty: ignore`; fix the underlying type issue. +- Do not add `from __future__ import annotations`; Python 3.14 native lazy annotations are the project standard. +- All 5 check IDs are represented in `scripts/ci.sh` / `scripts/ci.ps1` and enforced in `tests.yml` on push/merge (parallel jobs: suppression grep, ruff-format, ruff-check, ty, pytest). +- GitHub CI runs on `push`, `pull_request`, and `merge_group` so required checks validate merge queue candidates before they land. +- Repository protection should use rulesets: a non-bypassable main integrity ruleset requires pull requests, merge queue, required checks, and blocks direct/force pushes to `main`; a separate review ruleset may allow `Alishahryar1`/admins to bypass review only. +- Required status checks: set **required status checks** to **all** of those statuses (e.g. **Ban suppressions and legacy annotations**, **ruff-format**, **ruff-check**, **ty**, **pytest**—use the exact labels GitHub shows, which may be prefixed with **CI /**). Remove **ci** from required checks if it was previously added for the old gate job. + +## IDENTITY & CONTEXT + +- You are an expert Software Architect and Systems Engineer. +- Goal: Zero-defect, root-cause-oriented engineering for bugs; test-driven engineering for new features. Think carefully; no need to rush. +- Code: Write the simplest code possible. Keep the codebase minimal and modular. + +## ARCHITECTURE PRINCIPLES + +- **Shared utilities**: Put shared Anthropic protocol logic in neutral `src/free_claude_code/core/anthropic/` modules. Do not have one provider import from another provider's utils. +- **Failure ownership**: Keep canonical failure semantics and redaction SDK-free in `core/`; providers alone classify SDK/HTTP failures and own retries; protocol/API adapters alone choose wire error types and commit-boundary serialization. +- **DRY**: Extract shared base classes to eliminate duplication. Prefer composition over copy-paste. +- **Encapsulation**: Use accessor methods for internal state (e.g. `set_current_task()`), not direct `_attribute` assignment from outside. +- **Provider-specific config**: Keep provider-specific fields (e.g. `nim_settings`) in provider constructors, not in the base `ProviderConfig`. +- **Dead code**: Remove unused code, legacy systems, and hardcoded values. Use settings/config instead of literals (e.g. `settings.provider_type` not `"nvidia_nim"`). +- **Performance**: Use list accumulation for strings (not `+=` in loops), cache env vars at init, prefer iterative over recursive when stack depth matters. +- **Platform-agnostic naming**: Use generic names (e.g. `PLATFORM_EDIT`) not platform-specific ones (e.g. `TELEGRAM_EDIT`) in shared code. +- **No type ignores**: Do not add `# type: ignore` or `# ty: ignore`. Fix the underlying type issue. +- **Python 3.14 annotations**: Do not use `from __future__ import annotations`; rely on native lazy annotations and fix circular import boundaries instead of hiding them with annotation stringization. +- **Imports**: Prefer top-level imports. Avoid `TYPE_CHECKING` and local imports for first-party or required dependencies; if a top-level import creates a cycle, move shared types/protocols to a neutral owner. +- **Complete migrations**: When moving modules, update imports to the new owner and remove old compatibility shims in the same change unless preserving a published interface is explicitly required. +- **Maximum Test Coverage**: There should be maximum test coverage for everything, preferably live smoke test coverage to catch bugs early + +## COGNITIVE WORKFLOW + +1. **ANALYZE**: Read relevant files. Do not guess. +2. **PLAN**: Map out the logic. Identify root cause or required changes. Order changes by dependency. +3. **EXECUTE**: Fix the cause, not the symptom. Execute incrementally with clear commits. +4. **VERIFY**: Run `./scripts/ci.sh` or `.\scripts\ci.ps1`, plus relevant smoke tests when needed. Confirm the fix via logs or output. +5. **SPECIFICITY**: Do exactly as much as asked; nothing more, nothing less. +6. **PROPAGATION**: Changes impact multiple files; propagate updates correctly. +7. **VERSION**: If the commit touches production files on `main`, bump semver in the same commit (see [Versioning](#versioning-main)). + +## VERSIONING (MAIN) + +Every commit on `main` that changes a **production file** must include a semver bump in **`pyproject.toml`** in the **same commit**. Do not merge or push prod changes without updating the version. + +### Production files + +These paths count as production (runtime, packaging, or install surface): + +- `src/free_claude_code/api/`, `src/free_claude_code/cli/`, `src/free_claude_code/config/`, `src/free_claude_code/core/`, `src/free_claude_code/messaging/`, `src/free_claude_code/providers/` +- `src/free_claude_code/application/` +- `.env.example` +- `pyproject.toml` (dependencies, scripts, packaging) +- `scripts/install.sh`, `scripts/install.ps1`, `scripts/uninstall.sh`, `scripts/uninstall.ps1`, `scripts/ci.sh`, `scripts/ci.ps1` + +These do **not** require a version bump on their own: + +- `tests/`, `smoke/` +- Docs and assets: `README.md`, `assets/`, `AGENTS.md`, `CLAUDE.md` +- CI and repo config: `.github/`, `.gitignore` + +If a single commit mixes production and non-production edits, still bump the version. + +### Semver rules + +Use `[project].version` as `MAJOR.MINOR.PATCH`: + +- **PATCH** (`x.y.Z+1`): bug fixes, refactors with no user-visible behavior change, dependency updates, packaging/install fixes. +- **MINOR** (`x.Y+1.0`): backward-compatible features—new providers, admin fields, CLI commands, config options, or behavior additions. +- **MAJOR** (`X+1.0.0`): breaking changes—removed or renamed env vars, incompatible API/CLI/default changes, or migrations users must act on. + +When unsure between PATCH and MINOR, prefer PATCH for fixes and MINOR for new capability. + +### Required steps + +1. Classify the change and choose the bump level. +2. Update `version` in `pyproject.toml`. +3. Run `uv lock` so `uv.lock` reflects the new package version. +4. Include the version and lockfile updates in the same commit as the production change. + +Example commit on `main` after a packaging fix: bump `1.2.38` → `1.2.39`, run `uv lock`, commit together with the fix. + +## SUMMARY STANDARDS + +- Summaries must be technical and granular. +- Include: [Files Changed], [Logic Altered], [Verification Method], [Residual Risks] (if no residual risks then say none). + +## TOOLS + +- Prefer built-in tools (grep, read_file, etc.) over manual workflows. Check tool availability before use. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..fd228e4 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,1294 @@ +# Architecture + +This document is a maintainer-oriented map of Free Claude Code. It explains the +runtime boundaries, request flows, provider abstraction, configuration model, +optional messaging bridge, and verification strategy. + +For installation, provider setup, and user-facing usage, see +[README.md](README.md). This file focuses on where behavior lives in the codebase +and how contributors should extend it. + +## System Overview + +Free Claude Code is a local proxy for agent clients. It accepts Anthropic +Messages traffic from Claude Code and Pi clients and OpenAI Responses traffic +from Codex clients, routes the request to a configured upstream provider, and +preserves the wire protocol expected by the caller. + +There are three runtime surfaces: + +- HTTP proxy: FastAPI routes expose Anthropic-compatible, Responses-compatible, + health, model-listing, stop, and admin endpoints. +- CLI launchers: wrapper entrypoints prepare Claude Code, Codex, and Pi sessions + so they target the local proxy. +- Messaging bridge: optional Discord or Telegram adapters turn chat messages + into managed client CLI sessions. + +```mermaid +flowchart LR + ClaudeCode[Claude Code CLI and Extensions] --> ProxyAPI[FastAPI Proxy] + Codex[Codex CLI and Extensions] --> ProxyAPI + Pi[Pi Coding Agent] --> ProxyAPI + AdminUI[Local Admin UI] --> ProxyAPI + Bots[Discord or Telegram Bots] --> Messaging[Messaging Bridge] + Messaging --> ClientCLI[Managed Client CLI Sessions] + ClientCLI --> ProxyAPI + ProxyAPI --> Handlers[API Product Handlers] + Handlers --> Router[application ModelRouter] + Handlers --> Executor[application ProviderExecutor] + Executor --> Lease[Provider Generation Lease] + Lease --> Providers[ProviderRuntime] + Providers --> OpenAIChat[OpenAI Chat Provider Profiles And Specialized Adapters] +``` + +## Package Boundaries + +The installable wheel packages are declared in [pyproject.toml](pyproject.toml): + +- [src/free_claude_code/application/](src/free_claude_code/application/) is the dependency-leaf application boundary. It + owns immutable routing/model-metadata values, model routing, shared provider + execution, the consumer-facing `ProviderPort`, request-runtime lease ports, + task control, and deterministic request/readiness errors. It depends only on + configuration and core protocol-neutral logic. +- [src/free_claude_code/api/](src/free_claude_code/api/) is the HTTP adapter. It owns the FastAPI app, routes, API product + handlers, local optimizations, model-catalog responses, HTTP error mapping, + response commit timing, and Admin-specific ports. It consumes application and + protocol types instead of defining use cases or wire schemas. +- [src/free_claude_code/cli/](src/free_claude_code/cli/) owns console entrypoints, client CLI launchers, process/session + management, and client adapter contracts. +- [src/free_claude_code/config/](src/free_claude_code/config/) owns settings, provider metadata, filesystem paths, + logging setup, constants, and provider ID catalogs. +- [src/free_claude_code/core/](src/free_claude_code/core/) owns provider-neutral protocol logic: wire request and response + models, Anthropic conversion, SSE construction, OpenAI Responses conversion, + canonical execution-failure semantics, credential-safe diagnostics, token + counting, and structured trace helpers. It never classifies provider SDK or + HTTP client exceptions. +- [src/free_claude_code/messaging/](src/free_claude_code/messaging/) owns optional platform adapters, incoming message + handling, tree queues, transcript rendering, persistence, commands, and voice + support. +- [src/free_claude_code/providers/](src/free_claude_code/providers/) owns provider construction, the shared OpenAI-chat + provider, specialized adapters, SDK/HTTP failure classification, retry and + recovery policy, rate limiting, model listing, and concrete provider adapters. +- [src/free_claude_code/runtime/](src/free_claude_code/runtime/) is the process composition root. It owns application + startup and shutdown, provider generations, Admin runtime operations, and the + concrete wiring between API, providers, messaging, and managed CLI sessions. + +[tests/](tests/) contains deterministic unit and contract coverage. +[smoke/](smoke/) contains local and live product smoke tests that can launch +subprocesses or touch real services. + +Production package imports follow one least-privilege dependency policy. Every +listed edge is exercised by the current code; removing the last use of an edge +also removes that permission: + +| Package | Exact allowed direct dependencies | +| --- | --- | +| `config` | none | +| `core` | none | +| `application` | `config`, `core` | +| `messaging` | `core` | +| `providers` | `application`, `config`, `core` | +| `api` | `application`, `config`, `core` | +| `cli` | `config`, `core` | +| `runtime` | `api`, `application`, `cli`, `config`, `core`, `messaging`, `providers` | + +There is one exact exception: +`free_claude_code.cli.entrypoints` imports +`free_claude_code.runtime.bootstrap` because the installed server executable +delegates construction to the process composition root. The exception does not +permit any broader dependency from `cli` to `runtime`. Every new top-level +package or cross-package edge must be added to the policy deliberately. + +Internal modules do not import an ancestor package facade; package initializers +may import dependency leaves to publish supported exports. Code outside +`core.openai_responses` and `messaging.trees` consumes those owners through their +package facades. The supported top-level messaging extension surface is +`IncomingMessage`, `MessageScope`, `ManagedClaudeSessionProtocol`, +`ManagedClaudeSessionManagerProtocol`, and `OutboundMessenger`; workflow, +persistence, parsing, and mutable tree implementations remain internal. + +Optional voice dependencies also have exact lazy owners: + +| Dependency | Owner | +| --- | --- | +| `torch`, `transformers`, `librosa` | `messaging.transcription` | +| `riva.client` | `providers.nvidia_nim.voice` | + +They must be imported below a function boundary so importing the application or +server does not require an optional extra. Static AST enforcement cannot observe +dynamic imports. Deliberate provider factory loading is instead protected by the +provider catalog, supported-ID, and factory synchronization contract. + +[core/version.py](src/free_claude_code/core/version.py) is the sole runtime owner +of the FCC release version. It reads installed distribution metadata for +FastAPI/OpenAPI, FCC-owned CLI `--version` output, and the outbound web-tools +user agent. A source-only checkout without installed metadata reports the +explicit `0+unknown` fallback; runtime code never parses `pyproject.toml` or +duplicates a release literal. Client launcher arguments remain transparent to +their wrapped clients except for FCC-owned ephemeral provider configuration. + +The main ownership rule is that Anthropic and Responses protocol schemas and +shared protocol behavior belong in [src/free_claude_code/core/](src/free_claude_code/core/), while request routing and +provider execution belong in [src/free_claude_code/application/](src/free_claude_code/application/). Routes use core schemas +directly for wire validation and call application use cases. Provider modules use +the same concrete request types and neutral helpers instead of importing the API +adapter or another provider. +Protocol consumers use the public `core.anthropic` and +`core.openai_responses` facades. Low-level Anthropic core and provider modules +may import the dependency-leaf Anthropic `models.py` module directly so their +type dependency is explicit; Responses consumers outside its owner remain +facade-only. Package initialization and those leaves must remain import-order safe. +The model-list schema stays beside its API-owned construction policy in +`api/model_catalog.py`; there is no generic API model package. + +## Customer-Facing Contract + +FCC optimizes for installed user workflows, not internal compatibility. The +behavior that must be preserved is that these user-facing surfaces run correctly +for real prompts against supported providers: + +- `fcc-server` and the local Admin UI for configuring supported providers, + model routing, auth, server tools, messaging, and diagnostics. +- `fcc-claude`, Claude Code, and the Anthropic-compatible proxy behavior Claude + Code relies on, including streaming text, native/interleaved thinking, tool + use/results, model discovery, token counting, retries/recovery, and supported + local server-tool behavior. +- `fcc-codex`, Codex CLI/extensions, and the streaming OpenAI Responses behavior + Codex relies on, including native/interleaved reasoning, function and custom + tool calls, generated `/model` catalog support, Responses stream lifecycle + events, and Responses-to-Anthropic conversion at the adapter boundary. +- `fcc-pi`, Pi, and the Anthropic-compatible proxy behavior Pi relies on, + including an FCC-scoped model catalog, streaming text and reasoning, and tool + use/results. +- Configured Discord and Telegram messaging bridges, including command handling, + reply-based conversation branches, status updates, transcript rendering, + managed Claude/Codex task execution where configured, task stop/clear flows, + persistence, and optional voice-note transcription. +- Installation, update, init, and uninstall scripts insofar as they make the + above workflows available on a user's machine. + +Internal modules, class designs, helper APIs, route implementations, and tests +are not stable contracts. Refactors may replace or remove them when doing so +simplifies the system, improves correctness, or better matches these +architecture boundaries. When tests primarily encode an obsolete internal shape, +update the tests to assert the customer-facing behavior instead. Features, +compatibility shims, endpoints, or helper paths that do not serve one of the +surfaces above are not product requirements and should be removed rather than +preserved. + +The supported messaging extension surface consists of transport ingress values, +platform ports, and managed-session protocols. Tree aggregates, processors, +repositories, transition values, and package-level re-exports of those +implementation types are internal; they are not a versioned Python SDK surface. + +## Design Pressure And Refactor Targets + +The current package boundaries are intentional, but several modules still carry +large orchestration responsibilities. Treat these as refactor targets, not as +new places to add unrelated behavior: + +- [api/handlers/](src/free_claude_code/api/handlers/) owns customer-facing API product flows: + Claude Messages, OpenAI Responses, and token counting. Keep route handlers + thin, keep Claude-only behavior in the Messages handler, and use + [application/execution.py](src/free_claude_code/application/execution.py) only for shared + provider resolution, preflight, tracing, token counting, and streaming. +- [providers/openai_chat/](src/free_claude_code/providers/openai_chat/) owns the common upstream provider + behavior. It separates immutable vendor profiles from per-request stream + execution, recovery, request policy, and tool-call assembly. Shared + protocol rules belong in [src/free_claude_code/core/](src/free_claude_code/core/). +- [messaging/workflow.py](src/free_claude_code/messaging/workflow.py) coordinates messaging runtime + dependencies. Inbound turn intake, queued node execution, slash command + dependencies, and tree queue internals live in separate modules so new + behavior has one owner instead of growing the workflow object. +- [config/admin/](src/free_claude_code/config/admin/) owns Admin UI config behavior. Keep + provider fields catalog-driven, and keep manifest, source loading, validation, + env rendering, value presentation, and status metadata in their package owners. + +## Runtime Startup And Lifecycle + +Console scripts are registered in [pyproject.toml](pyproject.toml): + +- `fcc-server` and `free-claude-code` call `free_claude_code.cli.entrypoints:serve`. +- `fcc-init` calls `free_claude_code.cli.entrypoints:init`. +- `fcc-claude` calls `free_claude_code.cli.launchers.claude:launch`. +- `fcc-codex` calls `free_claude_code.cli.launchers.codex:launch`. +- `fcc-pi` calls `free_claude_code.cli.launchers.pi:launch`. + +[scripts/install.sh](scripts/install.sh) and [scripts/install.ps1](scripts/install.ps1) +install or update the uv tool plus optional voice extras. [scripts/uninstall.sh](scripts/uninstall.sh) +and [scripts/uninstall.ps1](scripts/uninstall.ps1) remove only the FCC uv tool and always +delete the managed `~/.fcc/` tree from [config/paths.py](src/free_claude_code/config/paths.py); they do not remove +uv, Claude Code, Codex, Pi, or uv-managed Python runtimes. [scripts/ci.sh](scripts/ci.sh) and +[scripts/ci.ps1](scripts/ci.ps1) mirror [.github/workflows/tests.yml](.github/workflows/tests.yml) +for local pre-push verification. + +[cli/entrypoints.py](src/free_claude_code/cli/entrypoints.py) starts the FastAPI server with Uvicorn. +`serve()` migrates legacy env files when needed, loads cached settings, runs a +supervised server instance, and can restart the server after admin config changes. +An Admin restart constructs the next instance only when the prior +`ApplicationRuntime` reports that its complete ownership graph closed. An +incomplete ASGI shutdown therefore exits the supervisor instead of overlapping +old and replacement graphs. On final shutdown it best-effort kills registered +child processes. + +[runtime/bootstrap.py](src/free_claude_code/runtime/bootstrap.py) is the single production composition function. The CLI +supervisor supplies one settings snapshot and its restart callback; bootstrap +configures logging, constructs the runtime owners and the configured voice + transcriber, constructs the explicit `ApiServices` composition value, and + returns the ASGI application. Provider request leases and task control satisfy + the consumer-owned ports in [application/ports.py](src/free_claude_code/application/ports.py); Admin operations retain + their inbound-adapter port in [api/ports.py](src/free_claude_code/api/ports.py). + +[api/app.py](src/free_claude_code/api/app.py) registers routers and exception +handlers around an explicit `ApiServices` value, then wraps the application in a +pure ASGI correlation boundary. The boundary surrounds the complete wire send; +it does not proxy streaming responses through `BaseHTTPMiddleware`. The API does +not read global settings or construct runtime resources. +`app.state.services` is the only runtime state published to FastAPI. + +[runtime/application.py](src/free_claude_code/runtime/application.py) owns process startup and shutdown, optional messaging, +the selected transcriber, the managed CLI session manager, Admin pending state, +and the injected restart callback. Shutdown is serialized and ordered: quiesce +messaging ingress, cancel and drain workflow/CLI work, flush persistence, close +delivery, close transcription, then close providers. An owner reference is +released only after its cleanup succeeds; cancellation or failure leaves the +incomplete graph retryable. Teardown stops at a failed dependency gate rather +than closing resources that still-live upstream work may need, and the ASGI +adapter reports that incomplete graph as lifespan shutdown failure. Cleanup is +completion-driven: generic timeouts do not cancel half-closed external resources; +the process supervisor owns any force-termination deadline. Optional messaging +startup remains nonfatal only when every partially constructed messaging owner +was successfully cleaned; incomplete startup cleanup fails the application +startup and retains the graph for the next close attempt. +[runtime/asgi.py](src/free_claude_code/runtime/asgi.py) drives that owner from ASGI lifespan messages and preserves +the concise startup-failure contract. + +[runtime/provider_manager.py](src/free_claude_code/runtime/provider_manager.py) is the only owner that constructs, publishes, +retires, and closes provider generations. Each request acquires a generation +lease before routing. Non-streaming responses release it after aggregation; +streaming responses bind it to FCC's response owner, which first closes the +entire body chain and then releases the lease on completion, failure, +cancellation, disconnect, or a response-start send failure. A provider-only +Admin Apply prepares a candidate and commits configuration before publication. +New requests then use the candidate while old streams finish on the retired +generation; its last lease closes it exactly once. Final shutdown rejects new +acquisition and replacement, waits every lease, and awaits the same +manager-owned cleanup task even if the initiating request or lease release is +cancelled. Failed generation or unpublished-candidate cleanup remains owned and +retryable; the manager does not become terminal or clear its model catalog until +every owned runtime closes. + +The manager also owns one application-lifetime provider model catalog and its +single best-effort discovery task. The catalog survives provider replacement. +This keeps the server model inventory stable without extra synchronization; +Claude clients may independently retain the list they fetched at startup. + +## Configuration Model + +[config/settings.py](src/free_claude_code/config/settings.py) owns the flat Pydantic Settings schema: +raw env fields, validation, and `get_settings()`. It should not own routing, +model-ref parsing, launcher defaults, or web-tool policy. Dotenv discovery lives +in [config/env_files.py](src/free_claude_code/config/env_files.py) and uses this order: + +1. repo-local `.env`; +2. managed `~/.fcc/.env`; +3. optional `FCC_ENV_FILE`, appended when present. + +Later dotenv files override earlier dotenv files. Process environment variables +also participate through Pydantic settings resolution. `ANTHROPIC_AUTH_TOKEN` +has an extra guard after settings are built: if any configured dotenv file +defines it, that dotenv value replaces a stale inherited shell token. Auth-token +source detection for startup warnings also belongs to `src/free_claude_code/config/env_files.py`. + +[config/paths.py](src/free_claude_code/config/paths.py) defines managed paths: + +- config directory: `~/.fcc`; +- managed env file: `~/.fcc/.env`; +- generated Codex model catalog: `~/.fcc/codex-model-catalog.json`; +- messaging state directory: `~/.fcc/agent_workspace`; +- server log: `~/.fcc/logs/server.log`. + +Model routing configuration is tiered: + +- `MODEL` is the fallback provider-prefixed model ref. +- `MODEL_OPUS`, `MODEL_SONNET`, and `MODEL_HAIKU` override Claude model tiers. +- `ENABLE_MODEL_THINKING` is the global thinking switch. +- `ENABLE_OPUS_THINKING`, `ENABLE_SONNET_THINKING`, and + `ENABLE_HAIKU_THINKING` optionally override thinking by tier. + +[config/model_refs.py](src/free_claude_code/config/model_refs.py) owns provider-prefixed model ref +parsing and configured `MODEL*` inventory. API routing and provider validation +depend on those helpers instead of adding behavior methods to Settings. + +[config/admin/](src/free_claude_code/config/admin/) owns the Admin UI config manifest and +managed env writes. Provider credential, local URL, proxy, and display-name +metadata is generated from [config/provider_catalog.py](src/free_claude_code/config/provider_catalog.py); +admin-only help text stays beside the admin manifest. The package splits source +loading, value presentation, validation, persistence, and provider status into +separate modules. [api/admin_routes.py](src/free_claude_code/api/admin_routes.py) exposes local-only +admin endpoints that load and validate config, then delegate runtime operations +through `AdminRuntimePort`. Provider-only Apply prepares prospective settings, +atomically commits the managed env, and publishes a new provider generation. +Restart-required changes preserve the existing supervisor restart flow and do +not publish an in-process generation first. + +[.env.example](.env.example) is the single install/init/admin template source. +It is packaged as a [src/free_claude_code/config/](src/free_claude_code/config/) resource for `fcc-init` and Admin UI +template defaults; runtime settings do not read it as a live config file. + +Admin routes call `require_loopback_admin()`, which rejects non-loopback clients +and non-local origins. + +## HTTP Request Flow + +[api/routes.py](src/free_claude_code/api/routes.py) exposes the public proxy routes: + +- `POST /v1/messages`: Anthropic Messages-compatible streaming requests. +- `POST /v1/responses`: OpenAI Responses-compatible requests. +- `POST /v1/messages/count_tokens`: Anthropic token counting. +- `GET /v1/models`: gateway and Claude-compatible model listing. +- `GET /health`: health check. +- `POST /stop`: stop CLI sessions and pending tasks. +- `HEAD` and `OPTIONS` probes for compatibility on supported endpoints. + +Admin routes live beside these in [api/admin_routes.py](src/free_claude_code/api/admin_routes.py). + +Authentication is handled by `require_api_key()` in +[api/dependencies.py](src/free_claude_code/api/dependencies.py). If `ANTHROPIC_AUTH_TOKEN` is blank, +proxy auth is disabled. Otherwise the token may be supplied through `x-api-key`, +`Authorization: Bearer ...`, or `anthropic-auth-token`. Comparisons use +constant-time matching. + +HTTP request correlation is owned at ingress. A pure ASGI boundary creates one +opaque FCC request ID before routing, places it in log context and request state, +and adds `request-id` while forwarding the actual `http.response.start` message. +OpenAI-compatible Responses and the shared model catalog also expose the same +value as `x-request-id`. Provider execution and trace events receive that +existing ID; they do not create a second identifier. Keeping the context around +the complete inner ASGI call preserves correlation during streaming and leaves +response lifetime finalization under the concrete response owner. Starlette's +outer server-error boundary bypasses user middleware for its catch-all 500, so +that one handler explicitly attaches the same ingress-owned headers. + +[api/handlers/](src/free_claude_code/api/handlers/) owns the public API product flows. +`MessagesHandler` validates non-empty messages, resolves models, applies +Claude-only safety-classifier and local optimization policy, handles local web +server tools, then streams Anthropic SSE. `ResponsesHandler` owns streaming-only +OpenAI Responses validation and conversion for Codex clients. `TokenCountHandler` +owns Anthropic token counting. Shared provider execution lives in +[application/execution.py](src/free_claude_code/application/execution.py). `ProviderExecutor` resolves the narrow +consumer-owned `ProviderPort`, synchronously preflights the upstream request, +emits trace events, counts input tokens, and returns an Anthropic SSE iterator. +It receives only a provider resolver and the few scalar collaborators it needs; +it does not depend on FastAPI, provider implementations, or the full settings +object. +[api/response_streams.py](src/free_claude_code/api/response_streams.py) owns public streaming egress +commit timing. It waits for the first protocol chunk before returning a +successful FCC-owned `StreamingResponse`. Its explicit replay iterator owns the +prefetched stream even before replay begins. The response itself owns one +idempotent finalization task: close the body transitively, then release the +provider-generation lease. This finalizer surrounds the real ASGI send and runs +to completion even when sending headers or the first body frame fails. A provider +execution failure before that commit boundary remains a real typed non-2xx JSON +response. Once FCC has finalized the failure, the response includes +`x-should-retry: false` so FCC retains ownership of upstream retry/recovery +without causing a second client retry loop. After the first chunk has escaped, +HTTP status is committed; Messages emits an Anthropic `event: error` and closes +without a synthetic `message_stop`; Responses emits `response.failed` with the +original response ID. Non-streaming Messages aggregate internally and return +non-2xx JSON for any terminal stream error, discarding incomplete content rather +than presenting a partial success. + +The public response chain follows a transitive close-ownership rule. A response +owns its replay iterator; replay owns the active protocol adapter; each protocol +adapter owns its direct input; tracing owns the executor body; the executor body +owns the provider iterator; and the provider runner owns its upstream stream. +Each of these response-chain owners closes its direct input on normal completion, +failure, cancellation, and early consumer close. Failures from those explicit +cleanup calls are trace metadata and cannot replace an established wire outcome; +a generation lease is released only after the body chain has finished closing. + +Ingress authentication, request validation, model routing, and deterministic +preflight failures remain ordinary HTTP errors and do not receive the terminal +provider-execution retry header. Missing provider configuration and a shutting +down request runtime are application-readiness errors: Messages serializes them +as Anthropic JSON, Responses serializes them as OpenAI JSON, and neither is +misclassified as an already-finalized provider execution failure. + +```mermaid +sequenceDiagram + participant Client + participant Route as FastAPIRoute + participant Handler as ProductHandler + participant Router as ModelRouter + participant Exec as ProviderExecutor + participant Manager as ProviderRuntimeManager + participant Lease as ProviderGenerationLease + participant Runtime as ProviderRuntimeGeneration + participant Provider + + Client->>Route: POST /v1/messages + Route->>Route: require_api_key + Route->>Manager: acquire current generation + Manager-->>Route: Lease(settings, provider resolver) + Route->>Handler: create message + Handler->>Router: resolve model and thinking + Handler->>Handler: server tools or optimizations + Handler->>Exec: stream routed request + Exec->>Lease: resolve provider + Lease->>Runtime: cached or new provider + Runtime->>Provider: cached or new provider + Exec->>Provider: preflight_stream + Exec->>Provider: stream_response + Provider-->>Client: Anthropic SSE events + Route->>Lease: release after complete body +``` + +OpenAI Responses uses the same provider execution primitive without importing +Claude-only message intercepts. `ResponsesHandler` delegates protocol work to +the `OpenAIResponsesAdapter` in +[src/free_claude_code/core/openai_responses/adapter.py](src/free_claude_code/core/openai_responses/adapter.py). The adapter +converts the Responses payload into an Anthropic Messages payload before +provider execution, then converts Anthropic SSE back to Responses SSE. + +## Model Routing + +[application/routing.py](src/free_claude_code/application/routing.py) resolves incoming client model names. +It supports two forms: + +- Direct provider model refs such as `nvidia_nim/nvidia/model-name`. +- Gateway model IDs decoded by [core/gateway_model_ids.py](src/free_claude_code/core/gateway_model_ids.py). + +If the incoming model is not direct, `ModelRouter` maps it by Claude tier. Names +containing `opus`, `sonnet`, or `haiku` use the matching tier override when set, +otherwise they fall back to `MODEL`. + +The router also resolves thinking. Gateway model IDs can force thinking on or +off; otherwise `ModelRouter` applies tier-specific thinking overrides or the +global setting. `ResolvedModel` carries only the selected route and thinking +decision; provider catalog metadata does not cross the application boundary. + +`GET /v1/models` advertises: + +- configured provider model refs; +- cached provider-discovered models; +- no-thinking variants when appropriate; +- built-in Claude model IDs for compatibility with Claude clients. + +Provider model discovery and optional thinking metadata live in the +application-level catalog owned by `ProviderRuntimeManager`. +`ProviderModelInfo.supports_thinking` alone owns discovered per-model thinking +support; provider-wide capabilities do not model thinking. The catalog is not +part of an individual provider generation, so a hot replacement does not erase +the last useful model list. Discovery failures retain prior entries. + +Codex-specific model picker shaping stays out of this route. `fcc-codex` fetches +the same `/v1/models` response at launch, converts FCC gateway IDs into +provider-selectable Codex slugs, writes `~/.fcc/codex-model-catalog.json`, and +passes it as `model_catalog_json`. Codex users open the native picker with +`/model`; FCC does not implement a proxy-level `/models` alias. + +## Provider Architecture + +Provider metadata is neutral and centralized in +[config/provider_catalog.py](src/free_claude_code/config/provider_catalog.py). Each +`ProviderDescriptor` declares provider ID, display name, locality, credential env +var, default base URL, settings attribute names, and proxy support. It does not +select a concrete adapter. + +[providers/runtime/](src/free_claude_code/providers/runtime/) owns construction details for one +closable provider generation: construction policy, resolved provider +configuration, lazy provider instances, provider-owned rate limiters, and +cleanup. [providers/runtime/factory.py](src/free_claude_code/providers/runtime/factory.py) +constructs ordinary provider IDs from `OPENAI_CHAT_PROFILES` and keeps a sparse +factory mapping only for adapters with real state or algorithms. The union of +those two construction owners must exactly equal the neutral provider catalog. +`ProviderRuntime` directly guarantees one provider and limiter per provider ID +within a generation; there is no pass-through cache object, process singleton, +or second limiter registry. Provider admission combines a strict proactive window with +one reactive backoff deadline. Positive backoffs can only extend that deadline, +and admission loops until proactive capacity and the final reactive check are +simultaneously available. The proactive timestamp is recorded only when that +check succeeds, so a concurrent 429/5xx cannot be missed, shortened, consume +unused quota, or release queued requests as an expiry burst. Retired generations +retain their own synchronization state until request leases drain, while new +generations and separate server instances never reuse it. Hot replacement +therefore begins with fresh quota state; an old and new generation enforce +independent budgets while old request leases drain. Application-level generation +publication, request leases, model metadata, discovery orchestration, and +configured-model validation belong to `ProviderRuntimeManager` in the runtime +package. This separates a single generation's resources from process-lifetime +state. + +[application/model_metadata.py](src/free_claude_code/application/model_metadata.py) owns the immutable +`ProviderModelInfo` value consumed by the application catalog. Provider-specific +model-list modules retain response parsing and construct that value directly; +there is no provider-layer alias for the former owner. + +[application/ports.py](src/free_claude_code/application/ports.py) defines the two provider operations consumed by request +execution: synchronous `preflight_stream()` and lazy `stream_response()`. API +handlers and application execution depend on that structural port, never on a +provider base class. Provider adapters implement it without registration or a +compatibility layer. + +[providers/base.py](src/free_claude_code/providers/base.py) defines provider-internal construction and lifecycle contracts: + +- `ProviderConfig`: shared provider settings such as API key, base URL, rate + limits, timeouts, proxy, thinking, and logging flags. It is a frozen internal + value whose base URL has already been resolved from the catalog. +- `BaseProvider`: the abstract implementation base for cleanup, model listing, + explicit preflight, and `stream_response()`. + +There is one upstream provider family: +[providers/openai_chat/](src/free_claude_code/providers/openai_chat/) implements the concrete +`OpenAIChatProvider` used by every OpenAI-compatible `/chat/completions` +upstream. `OpenAIChatProfile` contains immutable request policy, +postprocessors, and base-URL normalization for ordinary vendors. Configuration +differences therefore remain data rather than empty subclasses. The package also +owns the exactly typed private per-request runner, recovery operations, tool-call +assembly, and streamed usage handling. No obsolete generic transport namespace +or untyped provider backchannel remains. + +`OpenAIChatProvider` explicitly implements preflight by constructing the same +upstream request body it will later stream. `BaseProvider` makes that operation +abstract, so a new provider cannot silently omit the commit-boundary validation. +LM Studio composes the OpenAI-chat conversion first and its context-budget probe +second; conversion failure therefore cannot open a stream or run the probe. + +Providers call the OpenAI request policy for Anthropic-to-OpenAI conversion, +thinking replay selection, `extra_body`, and chat-completion field normalization. +Specialized provider packages remain only for true upstream quirks such as +Gemini thought signatures, NIM tool-schema aliases, retry downgrades, and NVCF +deployment-failure classification, or DeepSeek attachment/tool/thinking +compatibility. Ollama, llama.cpp, and LM Studio use their OpenAI-compatible Chat +Completions endpoints like the remote providers. DeepSeek intentionally uses its +OpenAI-compatible Chat Completions endpoint because that is the endpoint that +reports prompt-cache hit/miss counters; the provider maps those counters back +into Anthropic usage fields for Claude-compatible clients. Cloudflare uses its +account-scoped Workers AI OpenAI-compatible Chat Completions endpoint for +`@cf/...` model IDs, while account ID composition, model search, and +Cloudflare-specific reasoning deltas stay in the Cloudflare provider client. +OpenRouter remains specialized for model filtering and reasoning-detail stream +events. Wafer, Kimi, MiniMax, Fireworks, and Z.ai use ordinary declarative +profiles for their thinking, token, and `extra_body` policy. Z.ai is treated as +the GLM Coding Plan provider and uses Z.ai's Coding Plan OpenAI base. +Mistral La Plateforme keeps its native `reasoning_effort` and thinking-chunk +request/stream mapping inside +[providers/mistral/reasoning.py](src/free_claude_code/providers/mistral/reasoning.py), including its +fallback retry when a selected Mistral model rejects reasoning fields. +NIM reasoning budget control is also treated as a provider-owned best-effort +downgrade: if an upstream NIM deployment rejects explicit budget control, FCC +retries without the budget while preserving thinking enablement. + +Shared provider responsibilities include upstream rate limiting, model listing, +SDK/HTTP failure classification, safe diagnostic construction, HTTP resource +cleanup, thinking/tool handling, retry or recovery where supported, and +returning successful Anthropic SSE strings to the service layer. Final failures +cross that boundary as `ExecutionFailure`, not as provider-authored wire events. +Every provider receives the same concrete +`MessagesRequest` owned by the Anthropic protocol package. Known wire fields are +accessed through that model; `Any` and dynamic attribute lookup are reserved for +SDK response objects and genuinely open-ended nested extension payloads. +Provider-specific inputs that do not apply to other upstreams, such as +Cloudflare's account ID, stay in that provider's factory/client instead of being +added to shared `ProviderConfig`. +Gateway providers such as Vercel AI Gateway, Hugging Face, and Cohere are +profiles because their documented behavior is expressible as request policy. +GitHub Models remains specialized because it owns API headers, a separate model +catalog client, and capability filtering. The OpenAI-chat provider owns standard +streamed usage handling: it requests +`stream_options.include_usage`, consumes provider `prompt_tokens` and +`completion_tokens` when present, and falls back to local estimates when +providers omit or reject optional usage metadata. Provider modules only own true +usage quirks such as DeepSeek prompt-cache counters. + +### Adding A Provider + +1. Add provider metadata to [config/provider_catalog.py](src/free_claude_code/config/provider_catalog.py). +2. Add credentials and related settings to [config/settings.py](src/free_claude_code/config/settings.py) + and [.env.example](.env.example) when user configurable. +3. Let Admin UI provider credential, local URL, and proxy fields come from the + catalog. Add admin-only help text or provider-specific fields under + [config/admin/](src/free_claude_code/config/admin/) only when the generated manifest is + insufficient. +4. Add an `OpenAIChatProfile` under [providers/openai_chat/](src/free_claude_code/providers/openai_chat/) when + request policy fully describes the upstream. +5. Add a specialized provider package and sparse factory entry only when the + upstream owns state, model-list behavior, stream events, or retry algorithms + that a profile cannot express. +6. Add deterministic tests under [tests/providers/](tests/providers/) and any + relevant contract tests. +7. Add smoke coverage or smoke config in [smoke/](smoke/) when the provider can + be exercised live. +8. Update user-facing provider docs in [README.md](README.md) when users need new + setup instructions. + +## Protocol Conversion And Streaming Contracts + +[src/free_claude_code/core/anthropic/](src/free_claude_code/core/anthropic/) owns Anthropic-side protocol behavior: + +- `models.py` defines the permissive Messages and token-count wire requests, + content/tool/thinking blocks, and Anthropic response envelopes; +- trace-safe request snapshots stay beside those models so the generic trace + module remains protocol-independent and import-order safe; +- content and message conversion for OpenAI-compatible upstreams; +- request serialization primitives shared by provider request policies; +- tool schema and tool-result handling; +- thinking block handling; +- stream lifecycle through `src/free_claude_code/core/anthropic/streaming`, including the neutral + stream ledger, Anthropic SSE emitter, continuation-body construction, and tool repair; +- token counting and Anthropic-owned failure-kind-to-wire mapping. + +Shared stream behavior lives under +[src/free_claude_code/core/anthropic/streaming/](src/free_claude_code/core/anthropic/streaming/). The shared layer owns the +Anthropic content-block ledger, SSE serialization, continuation request +transformations, and tool JSON repair. It does not import `httpx` or the OpenAI +SDK and does not decide whether an upstream failure is retryable. + +[core/failures.py](src/free_claude_code/core/failures.py) defines the immutable, +protocol-neutral `FailureKind` and `ExecutionFailure`. The exception is the +value propagated through async iterators; its semantic fields are immutable, +while Python remains free to attach traceback/cause metadata during unwinding. +[core/diagnostics.py](src/free_claude_code/core/diagnostics.py) owns bounded error +body/cause extraction, credential redaction, safe traceback formatting, and +copyable request-ID diagnostics. Anthropic and Responses packages independently +map the canonical kind and status to their wire error types. + +[providers/failure_policy.py](src/free_claude_code/providers/failure_policy.py) +owns generic raw OpenAI SDK and `httpx` exception classification, +transient status/body inference, stable provider wording, and final diagnostic +construction for those failures. +Concrete adapters may supply one narrow semantic override for an upstream quirk +that the shared SDK cannot express correctly. The concrete adapter owns the +exact upstream marker, while the shared failure policy owns its canonical +meaning and wording. The limiter uses that meaning for retry qualification and +its existing provider-wide reactive backoff while retaining the raw exception, +so exhausted retries still receive the original HTTP status/body through the +shared redaction and diagnostic path. For NVCF's function-scoped failure this +deliberately keeps the simple one-limiter-per-provider policy; a degraded NIM +function can therefore briefly delay other NIM models during backoff. No +provider-specific marker enters `core/`, another provider, or an API adapter. +[providers/stream_recovery.py](src/free_claude_code/providers/stream_recovery.py) +owns the 0.75-second/65,536-byte holdback, four transparent early retries after +the first attempt, and five midstream recovery attempts. Provider opening keeps +its existing five-attempt exponential-backoff budget. `ExecutionFailure.retryable` +records provider-policy eligibility; it never tells the client to retry after FCC +has finalized the failure. + +The OpenAI-chat provider remains an upstream adapter: it converts OpenAI chat +chunks into ledger operations. After retry, continuation, and tool salvage are +exhausted, it discards uncommitted output or flushes committed output, closes +open content blocks, and raises `ExecutionFailure`. It never synthesizes a +terminal Anthropic error event. + +The public HTTP commit boundary solely decides whether a final failure can use +non-2xx JSON or must use a terminal protocol event; the protocol packages own +envelope and event serialization. Before the first public frame the boundary +returns typed non-2xx JSON with `x-should-retry: false`; after the first frame +Messages appends one Anthropic `event: error`, while Responses emits +`response.failed` with the original response ID. Non-streaming Messages catches +the same failure and discards its partial aggregate. Unexpected failures use the +same commit-state split but do not acquire provider retry semantics. + +[src/free_claude_code/core/openai_responses/](src/free_claude_code/core/openai_responses/) owns OpenAI Responses support: + +- the permissive `OpenAIResponsesRequest` ingress model used directly by the + FastAPI route and the protocol adapter; +- the `OpenAIResponsesAdapter` facade used by the API layer; +- streaming-only `/v1/responses` support for Codex/FCC workflows; +- Responses request conversion into Anthropic Messages payloads; +- Anthropic SSE conversion into Responses SSE; +- OpenAI-compatible error envelopes. + +The package intentionally does not implement the full OpenAI Responses surface. +FCC accepts omitted `stream` or `stream: true`; `stream: false` is rejected with +an OpenAI-shaped client error because installed FCC/Codex workflows only need +streaming. Request conversion, stream transformation, Anthropic SSE parsing, +Responses SSE event formatting, output item construction, tool identity mapping, +reasoning mapping, ID generation, and error envelope construction each live +behind the adapter boundary. The concrete request object crosses that boundary +unchanged; nested Responses input and tool data stays permissive and is +interpreted by the conversion functions. `stream.py` is the public streaming +entrypoint; +[src/free_claude_code/core/openai_responses/streaming/](src/free_claude_code/core/openai_responses/streaming/) owns the +block-indexed Responses stream assembler. The package separates Anthropic SSE +dispatch, block state, output ledger ordering, block completion, SSE event +builders, and error mapping. API code should depend on the adapter, not on +those internal module owners directly. Responses output payloads stay +OpenAI-shaped. Canonical execution failures enter the assembler directly, so +Responses does not infer provider failure semantics by parsing an Anthropic +terminal error. +Post-start Responses failures are assembler-owned: the active +`ResponsesStreamAssembler` emits `response.failed` so the terminal event keeps +the same `response.id`, output ledger, and usage state as the earlier +`response.created`. + +Responses custom tools are also boundary-owned. The adapter accepts native +Responses `custom` tool declarations, represents them internally as Anthropic +tools with a single string `input` field, and restores `custom_tool_call`, +`custom_tool_call_output`, and `response.custom_tool_call_input.*` shapes at the +Responses edge. Text or grammar format metadata is preserved as model guidance; +FCC does not validate custom-tool grammars. + +Responses reasoning is handled as protocol conversion, not provider policy. +`reasoning.effort = "none"` converts to a disabled Anthropic `thinking` +request; any other explicit Responses reasoning request enables Anthropic +thinking without translating OpenAI effort names into Anthropic token budgets. +Prior Responses `reasoning` input items replay plaintext `reasoning_text`, or +fallback `summary_text`, into assistant `reasoning_content`. Encrypted reasoning +input is ignored because the proxy cannot decrypt it. + +Provider thinking output maps back to Responses reasoning in the same block +order the upstream Anthropic stream produced. Anthropic `thinking` blocks become +Responses `reasoning` output items and `response.reasoning_text.*` stream +events. Anthropic `redacted_thinking` becomes a Responses `reasoning` item with +`encrypted_content`; the opaque value is not exposed as visible text and FCC +does not synthesize reasoning summaries. + +Provider code should delegate protocol details to these modules. Avoid copying +conversion code into individual providers, and avoid provider-to-provider imports +for shared Anthropic behavior. + +## Local Optimizations And Server Tools + +[api/optimization_handlers.py](src/free_claude_code/api/optimization_handlers.py) short-circuits +common low-value client requests before they reach a provider: + +- quota probes; +- command prefix detection; +- title generation; +- suggestion mode; +- filepath extraction. + +The Messages handler runs these only after model routing and after local server-tool +handling. Each optimization is controlled by settings flags. + +Claude Code auto-mode safety-classifier requests are a message-only routing +policy, not a short-circuit response. After routing, the Messages handler detects the +narrow classifier prompt shape and forces thinking off before provider execution +so Claude Code receives a parser-readable `yes` or +`no` verdict. + +Local `web_search` and `web_fetch` handling lives under +[api/web_tools/](src/free_claude_code/api/web_tools/). When `ENABLE_WEB_SERVER_TOOLS` is true, the +Messages handler can stream local Anthropic server-tool responses without sending the +request upstream. [api/web_tools/egress.py](src/free_claude_code/api/web_tools/egress.py) enforces URL +scheme and private-network restrictions for `web_fetch`. + +Anthropic server-tool definitions are never passed to upstream OpenAI Chat +providers because that conversion would be lossy. Forced `web_search` or +`web_fetch` requests are handled locally when `ENABLE_WEB_SERVER_TOOLS` is true; +otherwise the Messages handler rejects them before provider execution. + +## CLI Launchers And Managed Claude + +[cli/proxy_auth.py](src/free_claude_code/cli/proxy_auth.py) owns the neutral +proxy-auth token policy shared by client launchers. A blank configured token +becomes the local-only `fcc-no-auth` sentinel so clients cross their login gates +while FCC continues to run without API authentication. + +[cli/claude_env.py](src/free_claude_code/cli/claude_env.py) owns the canonical +Claude Code proxy environment used by every FCC-launched Claude process. It +strips inherited `ANTHROPIC_*` variables, sets `ANTHROPIC_BASE_URL`, enables +gateway model discovery, configures the auto-compact window, disables +nonessential Anthropic traffic, and always sets `ANTHROPIC_AUTH_TOKEN`. Blank +proxy auth uses the shared local-only sentinel so Claude Code reaches the proxy +instead of stopping at its login gate. + +[cli/launchers/claude.py](src/free_claude_code/cli/launchers/claude.py) owns the installed +`fcc-claude` launcher: + +- `fcc-claude` applies the shared proxy environment without changing the user's + Claude command arguments. + +[cli/launchers/codex.py](src/free_claude_code/cli/launchers/codex.py) owns the installed +`fcc-codex` launcher: + +- `fcc-codex` strips official OpenAI and Codex credential variables. +- It strips parent-only Codex thread, shell, permission, and origin context so + each launched client owns an independent runtime identity. +- It creates an ephemeral `fcc` model provider with `wire_api = "responses"` and + a base URL pointing at the local proxy `/v1` path. +- After proxy health succeeds, it fetches `/v1/models`, writes a generated Codex + `model_catalog_json` file under `~/.fcc/`, and injects that path so Codex's + native `/model` picker lists FCC provider slugs. Catalog generation is + fail-open: launch continues with a warning if the catalog cannot be prepared. +- It stores the proxy auth token in `FCC_CODEX_API_KEY` for Codex to read. + +[cli/launchers/pi.py](src/free_claude_code/cli/launchers/pi.py) owns the installed +`fcc-pi` launcher and [cli/launchers/pi_extension.ts](src/free_claude_code/cli/launchers/pi_extension.ts) +is its bundled Pi adapter: + +- Session commands load the extension from its absolute installed path and + scope Pi to the ephemeral `free-claude-code/**` provider, whose model IDs + retain FCC's nested `provider/model` routing reference. +- The extension fetches FCC's `/v1/models` catalog before registration, projects + only routable provider-model IDs, and registers an `anthropic-messages` + provider targeting the local proxy. Catalog failure is fail-closed so Pi never + silently falls back to a different provider. +- FCC connection values live only in child-process `FCC_PI_*` variables. Native + Pi credentials and persistent configuration remain untouched. +- Pi package-management, configuration, help, and version commands pass through + unchanged because they do not create an FCC-backed session. + +[cli/managed/](src/free_claude_code/cli/managed/) owns managed Claude Code subprocesses used by +Discord and Telegram messaging. Managed task invocations extend the same proxy +environment only with non-interactive terminal settings, optional `--resume`, +optional `--fork-session`, `--model opus`, and `--output-format stream-json`. +Messaging pins this Claude tier alias so phone sessions route through +`MODEL_OPUS` or the `MODEL` fallback instead of inheriting a user's interactive +`/model` picker state. Managed execution does not override Claude's +`plansDirectory`; plan files use Claude's native user-level location so the +project workspace may reside on any filesystem volume. The managed session +parser extracts persistent Claude session IDs and yields Claude stream-json +events to the messaging event parser. Managed Claude +also owns subprocess stderr diagnostic classification so known benign Claude +Code notices do not become messaging task errors, while unknown stderr remains +fatal. Before subprocess stop, the manager marks the session closing so new +lookups and aliases cannot borrow it; the session also marks itself terminal so +an already-issued reference cannot launch again. One lifecycle lock linearizes +that terminal transition with subprocess publication. Aliases plus PID +registration remain owned until exit is confirmed. Aggregate shutdown attempts +every distinct mapped or closing session, removes only confirmed successes, +reports a count-only failure, and leaves failures available for the next cleanup +attempt. Real-session registration is collision-safe and becomes durable tree +state only after the manager accepts it. + +Codex and Pi are supported through their installed launchers. FCC does not keep +internal managed session runners for them because no user-facing messaging +setting selects either client for Discord or Telegram. + +## Messaging Architecture + +Messaging is optional. [runtime/application.py](src/free_claude_code/runtime/application.py) calls +`create_messaging_components()` from +[messaging/platforms/factory.py](src/free_claude_code/messaging/platforms/factory.py) during startup. +If `MESSAGING_PLATFORM` is `none`, or if the selected platform token is missing, +the messaging bridge is skipped. + +`ApplicationRuntime` privately owns the selected platform runtime, the +`MessagingWorkflow`, configured `Transcriber`, and managed CLI session manager. +The workflow owns conversation snapshot restoration and terminal close: cancel +work, stop managed CLI sessions, await every processor-owned claim and recovery +task, then flush persistence. Interactive `/stop` keeps its bounded task-drain +behavior; only terminal close waits for full completion. +The API sees only the application-owned `TaskController` used to preserve +`/stop` behavior. + +The platform factory returns a `MessagingPlatformComponents` bundle from +[messaging/platforms/ports.py](src/free_claude_code/messaging/platforms/ports.py): a +`MessagingRuntime` with separate `quiesce()` and `close()` phases, an +`OutboundMessenger` for queued sends/edits/deletes, an optional +`VoiceCancellation` port for scoped and bulk voice cancellation during `/stop` +and `/clear`, and an optional immutable startup-notice intent. Workflow code +depends on these ports and values, not on Telegram or Discord SDK objects. + +Runtime adapters in +[messaging/platforms/telegram.py](src/free_claude_code/messaging/platforms/telegram.py) and +[messaging/platforms/discord.py](src/free_claude_code/messaging/platforms/discord.py) own SDK client +lifecycle, event subscription, inbound handoff, voice-note handoff, and one +injected `MessagingRateLimiter`. The platform factory creates a fresh limiter +for the selected runtime. `quiesce()` stops new SDK ingress and drains active +handlers while delivery remains available; after workflow tasks settle, +`close()` drains the outbox and limiter. Discord additionally retains, observes, +and drains its long-lived client task and inbound-handler tasks, so an SDK exit +after initial readiness immediately withdraws the runtime's connected state. +Telegram retries initialization and polling as separate repeatable steps; it +never restarts an already-running SDK application after polling bootstrap fails. +Separate application runtimes cannot share or stop each other's queue. Inbound +normalization lives in +[messaging/platforms/telegram_inbound.py](src/free_claude_code/messaging/platforms/telegram_inbound.py) +and [messaging/platforms/discord_inbound.py](src/free_claude_code/messaging/platforms/discord_inbound.py). +Outbound SDK calls live in +[messaging/platforms/telegram_io.py](src/free_claude_code/messaging/platforms/telegram_io.py) and +[messaging/platforms/discord_io.py](src/free_claude_code/messaging/platforms/discord_io.py). Shared +delivery policy lives in [messaging/platforms/outbox.py](src/free_claude_code/messaging/platforms/outbox.py), +which requires that limiter directly and owns queued send/edit/list-based delete, +dedup keys, and retained fire-and-forget tasks. Shutdown cancels and awaits both +queued limiter work and arbitrary outbox work; there is no optional unthrottled +fallback, and both owners reject admission once close begins. Workflow and command code request deletion of +message ID lists; platform IO decides whether to use native batch deletion +(Telegram) or internal per-message deletion (Discord). +Shared voice-note orchestration lives in +[messaging/platforms/voice_flow.py](src/free_claude_code/messaging/platforms/voice_flow.py), which owns +file-size validation, temp-file cleanup, transcription, error replies, and the +handoff to `IncomingMessage`. Before status delivery it reserves an opaque claim +in the `PendingVoiceRegistry` owned by [messaging/voice.py](src/free_claude_code/messaging/voice.py). +That registry atomically owns optional status binding, cancellation by either +message ID, and one child task that retains the exclusive handoff lease through +the complete workflow callback. An explicit stop or clear atomically removes +the exact claim and assumes ownership under the registry lock, then cancels and +joins its published child without holding that lock. Caller cancellation instead +keeps both aliases published while it cancels and drains the child, then removes +only that exact generation. Repeated cancellation cannot abandon either join or +pre-handoff cleanup, and fatal callback failures release the aliases before they +propagate. A cancellation that wins turns late status, transcription, callback +completion, or ordinary callback failure into cleanup-only work. Bulk +cancellation deduplicates the voice/status aliases and excludes the exact +current handoff child plus claims participating in a nested cancellation, so a +voice-transcribed `/stop` or `/clear` cannot cancel itself or form a recursive +join cycle. A stale flow cannot bind or remove a newer generation reusing the +same ID. Pending voice identities use the same +`(platform, chat_id)` `MessageScope` as tree references, so raw IDs from different +transports cannot share cancellation ownership. The flow depends only on the +consumer-owned `Transcriber` protocol. Bootstrap selects either the +instance-owned local Whisper `TranscriptionService` or the provider-owned +`NvidiaNimTranscriber`. Messaging no longer imports a provider adapter, and the +local service retains only one lazy pipeline for its immutable runtime settings; +caller cancellation waits for thread-backed transcription to actually exit +before temporary files, pipelines, or credentials are released. The NIM adapter +closes its per-call authenticated gRPC channel before that worker exits. Changing the +credential used by an active voice backend through Admin is therefore +restart-required, while the same provider credential remains hot-replaceable +when voice does not use it. + +[messaging/workflow.py](src/free_claude_code/messaging/workflow.py) contains `MessagingWorkflow`, the +platform-agnostic coordinator. It owns dependencies, render settings, the +state-transaction lock, global stop generation, per-chat clear generations, +stop/clear side effects, and shutdown-visible state. Each inbound turn snapshots +both applicable generations before external status I/O and rechecks them while +committing admission. Global `/stop` invalidates every older provisional turn; +standalone `/clear` invalidates only the invoking `MessageScope`. Before taking +the workflow lock, those commands cancel and join their applicable older voice +handoffs; they then cancel any matching tree that won admission during the join. +Reply-scoped commands first join the matching voice claim and then apply an exact +reference transition, so either the voice cancellation or admitted-tree +transition wins without double-counting. Stop operations return one typed +outcome after assigning every terminal status owner. The outcome records which message scopes own terminal +status feedback. Existing task statuses are the sole success UI when every +affected status is in the invoking scope; the command adapter sends a message +for a no-op, any cross-scope work, or the rare voice cancellation that wins +before a status ID is bound. Generation validation, tree admission, processor +publication, and persistence of the detached snapshot complete as one +workflow-owned operation; caller cancellation is restored only after that +transaction finishes. Stop and clear use the same completion-driven boundary, +so caller cancellation cannot leave a committed state transition without its +remaining cancellation and persistence cleanup. At startup it restores and normalizes +persisted state before ingress begins, then repairs interrupted platform +statuses after outbound delivery starts. Diagnostic detail policy is captured +at construction and passed into the processor; messaging does not read global +settings while executing callbacks or failures. + +Clearable lifecycle notices are workflow-owned rather than SDK-runtime side +effects. After transport readiness and restored-status repair, +`ApplicationRuntime` hands the platform's semantic startup-notice intent to the +workflow. The workflow owns platform rendering and snapshots the notice chat's +clear generation before sending outside its state lock. Once delivery returns a +message ID, a cancellation-safe finalizer briefly reacquires the lock: it records +the ID only if no standalone clear in that chat or startup cancellation crossed +the reservation; +otherwise it releases the lock and deletes the notice. Failed compensation +attempts to restore the ID to the current managed-message log so a later `/clear` can +retry. No platform I/O runs under the workflow lock. Ordinary notice-send +failure is privacy-safe and nonfatal, while cancellation before a delivery +receipt remains immediate and cannot create a phantom message ID. + +[messaging/turn_intake.py](src/free_claude_code/messaging/turn_intake.py) owns slash command dispatch, +status-echo filtering, initial status messages, and rendering detached frozen +admission/queue effects. The workflow records each accepted inbound prompt, +voice note, or command before intake performs external status I/O. Intake asks +the workflow to resolve and admit turns rather than receiving a mutable tree. +Reply lookup is always scoped by platform and chat; an unknown or cross-chat +reference starts an independent root. A previously resolved exact parent that a +concurrent clear removes is instead rejected as `PARENT_REMOVED`; intake then +best-effort deletes both the stale child prompt and its provisional status. +Duplicate delivery deletes only its provisional status. + +[messaging/node_runner.py](src/free_claude_code/messaging/node_runner.py) owns managed CLI session +lifecycle for queued nodes: parent-session fork/resume, session registration, +CLI event parsing, transcript/status updates, cancellation, error propagation, +and session cleanup. It executes an immutable `NodeClaim`; session, completion, +and failure writes return through `TreeQueueManager` with that claim identity. A +non-exit CLI error may render an error immediately, but only a terminal failure +propagates to queued descendants; a later successful exit is authoritative for +the same live, non-cancelled claim. A stale runner receives no snapshot and +cannot restore a branch removed by `/clear`. + +[messaging/event_parser.py](src/free_claude_code/messaging/event_parser.py) normalizes managed Claude +JSON events into low-level transcript events. +[messaging/transcript/](src/free_claude_code/messaging/transcript/) owns transcript assembly and +rendering: open content-block tracking, Task/subagent display state, segment +models, render context, and truncation. Platform markdown details stay in +[messaging/rendering/](src/free_claude_code/messaging/rendering/). + +[messaging/command_context.py](src/free_claude_code/messaging/command_context.py) defines the typed +dependency surface for `/stop`, `/clear`, and `/stats`; commands should not +depend on the concrete workflow object or on platform SDK runtimes. + +[messaging/trees/runtime.py](src/free_claude_code/messaging/trees/runtime.py) contains the +`MessageTree` aggregate. Its lock is private, and complete operations own every +graph/queue/claim invariant: add-and-admit, enqueue-or-claim, +finish-and-claim-next, semantic state writes, cancellation, and atomic branch +removal. Logical `parent_id` owns execution/session ancestry, while +`parent_reference_id` records the exact prompt or FCC status that received the +platform reply. The aggregate derives literal reference adjacency from those +canonical fields instead of maintaining a second graph. Removing a prompt +therefore removes its status and every literal descendant; removing a status +preserves its prompt and prompt-level siblings while invalidating that prompt's +session. `TreeIdentity` is `(platform, chat_id, root_message_id)`, because +platform message IDs are not globally unique. Every execution receives a fresh +opaque claim ID, so a task from an older runtime generation cannot mutate or +collide with a re-admitted tree. Active execution ownership is separate from the +node's UI state: cancellation can still reach a task that already rendered +complete/error but is cleaning up, while a cancellation tombstone prevents late +success from reviving a stopped node. Only the matching finish transition may +select the FIFO successor. Duplicate node/status admission and terminal-node +re-admission are rejected without changing active state. + +[messaging/trees/transitions.py](src/free_claude_code/messaging/trees/transitions.py) owns frozen, +slotted claims, queue entries, read views, and cancellation/removal effects. +These values copy the UI and execution facts callers need and never contain a +mutable `MessageNode`, lock, or `asyncio.Task`. + +[messaging/trees/manager.py](src/free_claude_code/messaging/trees/manager.py) is the only external +tree facade. It keeps one structural lock across aggregate membership changes +and repository index publication/removal, registers node and status references +together under `MessageScope`, coordinates cross-tree requests, and returns +transition-owned snapshots. Claim completion re-enters that same lock: the +manager verifies the exact aggregate is still published and publishes any +successor task slot before a competing detach can commit. Cancellation and +removal entrypoints finish their exact transition despite caller cancellation, +so a committed detach cannot lose its persistence result. Reply `/clear` is one +exact-reference cancel-and-detach transition before platform I/O; standalone +clear atomically detaches every aggregate in the invoking scope before task +draining. Reply `/stop` cancels exactly one request; its matching finisher +releases execution ownership and advances the next eligible queued request. +Global `/stop` drains every queue instead, and reply `/clear` removes the selected +literal message subtree before any survivor can advance. Separate scopes and +trees still progress independently. Subtree transitions return exact reference +IDs for both repository unindexing and authorized platform deletion, including +user-authored messages selected by the explicit command. +[messaging/trees/repository.py](src/free_claude_code/messaging/trees/repository.py) +is manager-private and owns only aggregate/reference indexes. +[messaging/trees/processor.py](src/free_claude_code/messaging/trees/processor.py) owns every +`asyncio.Task`, keyed by globally unique claim ID. It publishes a task slot +before task creation, which is safe under Python's eager task factory, then +launches claims returned by the aggregate, cancels the exact matching task, +drains cleanup outside tree locks, and feeds matching completion back to the +aggregate. Cancellation before a task body starts has an explicit recovery path; +the cancellation flag is rechecked after callbacks, and best-effort UI callback +failure cannot prevent successor launch. If a node processor unexpectedly +escapes, the processor routes failure through the manager-owned aggregate +transition; the workflow persists its snapshot and schedules its UI effect as +normal queue advancement continues. The processor's completion event covers the +published slot from launch through normal completion, successor publication, +and pre-run recovery, so terminal workflow close cannot release delivery while +cleanup is still active. A failed aggregate-completion callback releases its +finished task slot, records the failure, and hands it to the terminal waiter +exactly once; a failed close therefore retains the workflow for reconciliation +instead of hanging on ownership that no longer exists. +[messaging/trees/node.py](src/free_claude_code/messaging/trees/node.py) owns +`MessageNode` and `MessageState`; each node keeps only the copied scope and +prompt needed by the aggregate rather than retaining a mutable ingress value, +[messaging/trees/graph.py](src/free_claude_code/messaging/trees/graph.py) owns parent/child and +status-message lookup state, and +[messaging/trees/snapshot.py](src/free_claude_code/messaging/trees/snapshot.py) owns typed persisted +conversation snapshots. New snapshots serialize scoped trees as a list, while +loading derives scope from existing pre-scope `sessions.json` tree roots. Nodes +persist logical and exact-reference parent relations; runtime child indexes are +rebuilt on restore, and transport ingress payloads do not leak into aggregate +storage. Old snapshots without an exact parent reference attach conservatively +to the logical parent prompt. A cleared optional status is valid only for an +inert node; runnable restored nodes must still have a status. +A malformed tree carrying neither current scope nor legacy root ingress is +reported and skipped because assigning it to an inferred chat would violate the +same ownership boundary. + +[messaging/session/](src/free_claude_code/messaging/session/) persists typed conversation snapshots +and message IDs to a JSON file under the managed messaging state directory. +`SessionStore` reads existing `sessions.json` files but exposes typed snapshot +APIs to runtime code and deep-copies snapshot ingress and egress so no caller +shares mutable persisted state. Debounced atomic writes live in +[messaging/session/persistence.py](src/free_claude_code/messaging/session/persistence.py). One writer +lock serializes physical replaces, and a generation check under that lock +prevents an older timer snapshot from landing after a newer flush or clear. +Timer-triggered saves are best effort and leave the store dirty on failure; +explicit flushes and authoritative writes propagate failure while preserving +that dirty state for retry. Successful retry writes the current in-memory +snapshot and is the only operation that marks it clean. +Standalone `/clear` detaches and drains only the invoking scope, then writes an +authoritative scoped removal while other chats remain intact. Per-chat deletion +ownership lives in +[messaging/session/managed_message_log.py](src/free_claude_code/messaging/session/managed_message_log.py). +The registry accepts managed inbound prompts, voice notes, and commands as well +as FCC output. It migrates legacy `message_log` entries and persists the final +shape as `managed_messages`. Startup notices use the same registry. An incoming +standalone `/clear` defers insertion because the command handler already owns its +ID on success; this prevents the command from evicting an older deletion target +when an explicit cap is configured. Failed or cancelled clear attempts record +the command before propagating so a later clear can discover it. + +`/clear` commits FCC state cleanup first and then best-effort deletes the exact +authorized message-ID set through the list-based outbound port. Standalone clear +deletes every tracked user and FCC message in its chat; reply clear deletes only +the selected literal reply subtree plus its command. Discord/Telegram can still +reject individual deletions for platform reasons such as permissions, age, or +missing messages; such failures never restore cleared FCC state. + +```mermaid +sequenceDiagram + participant Runtime as DiscordOrTelegramRuntime + participant Outbound as OutboundMessenger + participant Workflow as MessagingWorkflow + participant Intake as MessagingTurnIntake + participant Queue as TreeQueueManager + participant Runner as MessagingNodeRunner + participant Manager as ManagedClaudeSessionManager + participant CLI as ClaudeCode + participant Proxy as LocalProxy + + Runtime->>Workflow: IncomingMessage + Workflow->>Intake: handle inbound turn + Intake->>Queue: create or extend message tree + Queue->>Runner: process node in order + Runner->>Manager: get_or_create_session + Manager->>CLI: launch JSON stream task + CLI->>Proxy: provider-backed API calls + CLI-->>Runner: parsed stdout events + Runner-->>Outbound: status and transcript updates +``` + +## Observability, Diagnostics, And Safety + +[core/trace.py](src/free_claude_code/core/trace.py) emits structured trace events across stages such +as ingress, routing, provider, egress, messaging, and client CLI execution. Trace +payloads are intended to connect API, provider, CLI, and messaging activity +without requiring raw transport logs by default. + +Logging defaults are conservative: + +- API payloads and SSE events are not logged raw unless explicitly enabled. +- Provider and application errors log metadata by default; verbose traceback and + message logging are opt-in. +- Messaging text, transcription previews, CLI diagnostics, and detailed + messaging exception strings are controlled by separate diagnostic flags. +- Process logging, server/managed-CLI authentication, and messaging diagnostics + are captured by their lifecycle owners at construction. Admin marks those + settings restart-required so an Apply cannot report success while an existing + runtime continues using stale security or privacy policy. +- Values under keys that look like API keys, authorization, tokens, or secrets + are redacted by trace helpers where structured traces are emitted. + +Important safety boundaries: + +- Admin UI and admin APIs are loopback-only. +- Proxy API auth is controlled by `ANTHROPIC_AUTH_TOKEN`. +- `web_fetch` egress defaults to configured URL schemes and blocks private + network targets unless explicitly allowed. +- Local provider URLs are user-configurable, but local-provider status checks are + exposed only through the local admin API. + +## Testing And CI Strategy + +Deterministic tests live under [tests/](tests/). They cover API routes, config, +provider conversion, upstream adapters, streaming contracts, messaging, CLI +adapters, import boundaries, provider catalog contracts, and other invariants. +The import-boundary contract derives every static production edge with one AST +scanner and checks the package matrix, exact exceptions, facade ownership, and +lazy optional imports. The resulting first-party module graph must remain +acyclic. The same contract rejects untyped provider collaborators and private +provider access from helper modules. These tests protect current architectural +properties rather than preserving deleted modules or an exact internal file +layout. + +Live and local product tests live under [smoke/](smoke/). See +[smoke/README.md](smoke/README.md) for target taxonomy, environment variables, +failure classes, and examples. Smoke tests can launch subprocesses, call real +providers, touch local model servers, and optionally send bot messages. + +CI is defined in [.github/workflows/tests.yml](.github/workflows/tests.yml). It +enforces: + +- `Ban type ignore suppressions`; +- `ruff-format`; +- `ruff-check`; +- `ty`; +- `pytest`. + +Contributor verification commands: + +```powershell +uv run ruff format +uv run ruff check +uv run ty check +uv run pytest +``` + +For docs-only architecture changes, a source-link and accuracy review is usually +sufficient. Full CI can still be run when the doc accompanies runtime changes or +when maintainers want branch-level assurance. + +## Extension Checklists + +### Add An Admin Setting + +1. Add or expose the setting in [config/settings.py](src/free_claude_code/config/settings.py). +2. Add the template key to [.env.example](.env.example) if users configure it. +3. Add a `ConfigFieldSpec` under [config/admin/](src/free_claude_code/config/admin/), or add + provider catalog metadata when the setting is provider credential, local URL, + proxy, or display-name metadata. +4. Mark `restart_required` or `session_sensitive` when runtime state cannot be + updated in place. +5. Add tests under [tests/api/](tests/api/) or [tests/config/](tests/config/). + +### Add Or Change A Client Surface + +1. For an installed wrapper, add or update a launcher under + [cli/launchers/](src/free_claude_code/cli/launchers/) and keep credential stripping local to that + client. +2. For messaging-managed execution, update [cli/managed/](src/free_claude_code/cli/managed/) only + when Discord or Telegram should actually run a different managed client. +3. Ensure managed task parsing emits the event shapes expected by + [messaging/event_parser.py](src/free_claude_code/messaging/event_parser.py) and + [messaging/node_event_pipeline.py](src/free_claude_code/messaging/node_event_pipeline.py). +4. Add launcher, managed-session, and customer-flow tests under + [tests/cli/](tests/cli/) and [tests/messaging/](tests/messaging/). + +### Add A Messaging Platform + +1. Implement a `MessagingRuntime`, `OutboundMessenger`, and inbound normalizer + under [messaging/platforms/](src/free_claude_code/messaging/platforms/). +2. Reuse [messaging/platforms/outbox.py](src/free_claude_code/messaging/platforms/outbox.py) for + queued outbound delivery and + [messaging/platforms/voice_flow.py](src/free_claude_code/messaging/platforms/voice_flow.py) for + voice-note handoff when the platform supports audio. +3. Add construction logic to + [messaging/platforms/factory.py](src/free_claude_code/messaging/platforms/factory.py). +4. Add settings and admin fields for tokens, allowlists, and platform-specific + runtime options. +5. Add rendering profile support in + [messaging/rendering/profiles.py](src/free_claude_code/messaging/rendering/profiles.py) if needed. +6. Add deterministic runtime/outbound/workflow tests and optional live smoke + targets. + +### Add Protocol Behavior + +1. Put shared Anthropic behavior under [src/free_claude_code/core/anthropic/](src/free_claude_code/core/anthropic/). +2. Put OpenAI Responses behavior under + [src/free_claude_code/core/openai_responses/](src/free_claude_code/core/openai_responses/). +3. Keep provider-specific request quirks inside the provider profile or specialized + provider subclass. +4. Add stream contract tests under [tests/contracts/](tests/contracts/) or + [tests/core/](tests/core/) when event shape or ordering changes. +5. Add provider tests when the behavior changes upstream request or response + handling. + +## Maintenance Rules For This Document + +Update this file when a change adds or meaningfully changes: + +- a top-level package or installable runtime boundary; +- a public route or wire protocol; +- startup, shutdown, or resource ownership; +- configuration precedence or managed config behavior; +- provider runtime, catalog, or upstream-adapter architecture; +- model routing or thinking behavior; +- CLI adapter behavior; +- messaging platform behavior; +- protocol conversion or streaming contracts; +- CI, smoke, or verification strategy. + +Docs-only changes to this file do not require a semver bump. Production code +changes still follow the versioning rules in [AGENTS.md](AGENTS.md) and +[CLAUDE.md](CLAUDE.md). + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..164e80d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,105 @@ +# AGENTIC DIRECTIVE + +> Keep AGENTS.md and CLAUDE.md identical. + +## CODING ENVIRONMENT + +- Install astral uv using "curl -LsSf https://astral.sh/uv/install.sh | sh" if not already installed and if already installed then update it to the latest version +- Install Python 3.14.0 stable using `uv python install 3.14.0` if not already installed (requires uv >=0.9; see `[tool.uv] required-version` in `pyproject.toml`) +- Always use `uv run` to run files instead of the global `python` command. +- Current uv ruff formatter is set to py314 which has supports multiple exception types without paranthesis (except TypeError, ValueError:) +- Read `.env.example` for environment variables. +- All CI checks must pass; failing checks block merge. +- Add tests for new changes (including edge cases). +- Before pushing, prefer `./scripts/ci.sh` (macOS/Linux) or `.\scripts\ci.ps1` (Windows) to run the local CI sequence; requires `uv` on PATH. The local scripts run Ruff in repair mode (`ruff format`, then `ruff check --fix`) before type checking and tests. +- Use `--only` / `--skip` (PowerShell: `-Only` / `-Skip`) to run a subset when iterating; use `--dry-run` to print commands without running them. +- GitHub CI remains check-only for Ruff (`ruff format --check`, `ruff check`) so branch protection verifies committed code. +- Fall back to individual repair commands when debugging local failures: `uv run ruff format`, `uv run ruff check --fix`, `uv run ty check`, `uv run pytest -v --tb=short`. Use GitHub-style checks only when verifying enforcement locally: `uv run ruff format --check`, `uv run ruff check`. +- Do not add `# type: ignore` or `# ty: ignore`; fix the underlying type issue. +- Do not add `from __future__ import annotations`; Python 3.14 native lazy annotations are the project standard. +- All 5 check IDs are represented in `scripts/ci.sh` / `scripts/ci.ps1` and enforced in `tests.yml` on push/merge (parallel jobs: suppression grep, ruff-format, ruff-check, ty, pytest). +- GitHub CI runs on `push`, `pull_request`, and `merge_group` so required checks validate merge queue candidates before they land. +- Repository protection should use rulesets: a non-bypassable main integrity ruleset requires pull requests, merge queue, required checks, and blocks direct/force pushes to `main`; a separate review ruleset may allow `Alishahryar1`/admins to bypass review only. +- Required status checks: set **required status checks** to **all** of those statuses (e.g. **Ban suppressions and legacy annotations**, **ruff-format**, **ruff-check**, **ty**, **pytest**—use the exact labels GitHub shows, which may be prefixed with **CI /**). Remove **ci** from required checks if it was previously added for the old gate job. + +## IDENTITY & CONTEXT + +- You are an expert Software Architect and Systems Engineer. +- Goal: Zero-defect, root-cause-oriented engineering for bugs; test-driven engineering for new features. Think carefully; no need to rush. +- Code: Write the simplest code possible. Keep the codebase minimal and modular. + +## ARCHITECTURE PRINCIPLES + +- **Shared utilities**: Put shared Anthropic protocol logic in neutral `src/free_claude_code/core/anthropic/` modules. Do not have one provider import from another provider's utils. +- **Failure ownership**: Keep canonical failure semantics and redaction SDK-free in `core/`; providers alone classify SDK/HTTP failures and own retries; protocol/API adapters alone choose wire error types and commit-boundary serialization. +- **DRY**: Extract shared base classes to eliminate duplication. Prefer composition over copy-paste. +- **Encapsulation**: Use accessor methods for internal state (e.g. `set_current_task()`), not direct `_attribute` assignment from outside. +- **Provider-specific config**: Keep provider-specific fields (e.g. `nim_settings`) in provider constructors, not in the base `ProviderConfig`. +- **Dead code**: Remove unused code, legacy systems, and hardcoded values. Use settings/config instead of literals (e.g. `settings.provider_type` not `"nvidia_nim"`). +- **Performance**: Use list accumulation for strings (not `+=` in loops), cache env vars at init, prefer iterative over recursive when stack depth matters. +- **Platform-agnostic naming**: Use generic names (e.g. `PLATFORM_EDIT`) not platform-specific ones (e.g. `TELEGRAM_EDIT`) in shared code. +- **No type ignores**: Do not add `# type: ignore` or `# ty: ignore`. Fix the underlying type issue. +- **Python 3.14 annotations**: Do not use `from __future__ import annotations`; rely on native lazy annotations and fix circular import boundaries instead of hiding them with annotation stringization. +- **Imports**: Prefer top-level imports. Avoid `TYPE_CHECKING` and local imports for first-party or required dependencies; if a top-level import creates a cycle, move shared types/protocols to a neutral owner. +- **Complete migrations**: When moving modules, update imports to the new owner and remove old compatibility shims in the same change unless preserving a published interface is explicitly required. +- **Maximum Test Coverage**: There should be maximum test coverage for everything, preferably live smoke test coverage to catch bugs early + +## COGNITIVE WORKFLOW + +1. **ANALYZE**: Read relevant files. Do not guess. +2. **PLAN**: Map out the logic. Identify root cause or required changes. Order changes by dependency. +3. **EXECUTE**: Fix the cause, not the symptom. Execute incrementally with clear commits. +4. **VERIFY**: Run `./scripts/ci.sh` or `.\scripts\ci.ps1`, plus relevant smoke tests when needed. Confirm the fix via logs or output. +5. **SPECIFICITY**: Do exactly as much as asked; nothing more, nothing less. +6. **PROPAGATION**: Changes impact multiple files; propagate updates correctly. +7. **VERSION**: If the commit touches production files on `main`, bump semver in the same commit (see [Versioning](#versioning-main)). + +## VERSIONING (MAIN) + +Every commit on `main` that changes a **production file** must include a semver bump in **`pyproject.toml`** in the **same commit**. Do not merge or push prod changes without updating the version. + +### Production files + +These paths count as production (runtime, packaging, or install surface): + +- `src/free_claude_code/api/`, `src/free_claude_code/cli/`, `src/free_claude_code/config/`, `src/free_claude_code/core/`, `src/free_claude_code/messaging/`, `src/free_claude_code/providers/` +- `src/free_claude_code/application/` +- `.env.example` +- `pyproject.toml` (dependencies, scripts, packaging) +- `scripts/install.sh`, `scripts/install.ps1`, `scripts/uninstall.sh`, `scripts/uninstall.ps1`, `scripts/ci.sh`, `scripts/ci.ps1` + +These do **not** require a version bump on their own: + +- `tests/`, `smoke/` +- Docs and assets: `README.md`, `assets/`, `AGENTS.md`, `CLAUDE.md` +- CI and repo config: `.github/`, `.gitignore` + +If a single commit mixes production and non-production edits, still bump the version. + +### Semver rules + +Use `[project].version` as `MAJOR.MINOR.PATCH`: + +- **PATCH** (`x.y.Z+1`): bug fixes, refactors with no user-visible behavior change, dependency updates, packaging/install fixes. +- **MINOR** (`x.Y+1.0`): backward-compatible features—new providers, admin fields, CLI commands, config options, or behavior additions. +- **MAJOR** (`X+1.0.0`): breaking changes—removed or renamed env vars, incompatible API/CLI/default changes, or migrations users must act on. + +When unsure between PATCH and MINOR, prefer PATCH for fixes and MINOR for new capability. + +### Required steps + +1. Classify the change and choose the bump level. +2. Update `version` in `pyproject.toml`. +3. Run `uv lock` so `uv.lock` reflects the new package version. +4. Include the version and lockfile updates in the same commit as the production change. + +Example commit on `main` after a packaging fix: bump `1.2.38` → `1.2.39`, run `uv lock`, commit together with the fix. + +## SUMMARY STANDARDS + +- Summaries must be technical and granular. +- Include: [Files Changed], [Logic Altered], [Verification Method], [Residual Risks] (if no residual risks then say none). + +## TOOLS + +- Prefer built-in tools (grep, read_file, etc.) over manual workflows. Check tool availability before use. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9f9b557 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,63 @@ +# Contributing + +Thanks for helping improve Free Claude Code. Keep changes focused, test the behavior you change, and preserve the public Claude Code and Codex workflows. + +## Before Opening A Pull Request + +- Open an issue before proposing README changes. +- Do not open Docker integration pull requests. +- For bugs, include every model mapping, the active model when the failure occurred, the complete error, and reproducible steps. +- Add focused tests for behavior changes and relevant edge cases. +- Read [ARCHITECTURE.md](ARCHITECTURE.md) before changing package boundaries, providers, protocol conversion, launchers, or messaging. + +## Development Setup + +Install [uv](https://docs.astral.sh/uv/) and Python 3.14, then run directly from the checkout: + +```bash +git clone https://github.com/Alishahryar1/free-claude-code.git +cd free-claude-code +uv python install 3.14.0 +uv run fcc-server +``` + +Use `uv run` for Python commands. Do not run the project with a global Python interpreter. + +## Quality Checks + +Run the complete local CI sequence before opening a pull request: + +```bash +./scripts/ci.sh +``` + +```powershell +.\scripts\ci.ps1 +``` + +Useful iteration flags are `--only`, `--skip`, and `--dry-run` on macOS/Linux, or `-Only`, `-Skip`, and `-DryRun` in PowerShell. + +Individual repair and test commands: + +```bash +uv run ruff format +uv run ruff check --fix +uv run ty check +uv run pytest -v --tb=short +``` + +GitHub CI runs Ruff in check-only mode and also bans `# type: ignore`, `# ty: ignore`, and legacy annotation workarounds. Fix underlying typing and import-boundary problems instead of suppressing them. + +## Project Standards + +- Target Python 3.14 and rely on native lazy annotations; do not add `from __future__ import annotations`. +- Python 3.14 supports multiple exception types without parentheses, such as `except TypeError, ValueError:`. +- Keep shared Anthropic protocol behavior under `src/free_claude_code/core/anthropic/` rather than importing utilities from another provider. +- Keep provider-specific configuration in the provider that owns it. +- Remove dead compatibility code when completing migrations unless preserving a published interface is explicitly required. + +## Versioning + +Changes to runtime code, packaging, dependencies, or install/CI scripts require a semantic version bump in `pyproject.toml` and a matching `uv lock` update in the same commit. Documentation, tests, smoke coverage, and repository configuration do not require a version bump by themselves. + +See [ARCHITECTURE.md](ARCHITECTURE.md) for extension checklists and the full system design. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e174983 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ali Khokhar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..16b05b4 --- /dev/null +++ b/README.md @@ -0,0 +1,431 @@ +
+ +# 🤖 Free Claude Code + +Use Claude Code, Codex, Pi, editor extensions, or chat bots through your own provider-backed proxy. + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) +[![Python 3.14](https://img.shields.io/badge/python-3.14-3776ab.svg?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/downloads/) +[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json&style=for-the-badge)](https://github.com/astral-sh/uv) +[![Tested with Pytest](https://img.shields.io/badge/testing-Pytest-00c0ff.svg?style=for-the-badge)](https://github.com/Alishahryar1/free-claude-code/actions/workflows/tests.yml) +[![Type checking: Ty](https://img.shields.io/badge/type%20checking-ty-ffcc00.svg?style=for-the-badge)](https://pypi.org/project/ty/) +[![Code style: Ruff](https://img.shields.io/badge/code%20formatting-ruff-f5a623.svg?style=for-the-badge)](https://github.com/astral-sh/ruff) +[![Logging: Loguru](https://img.shields.io/badge/logging-loguru-4ecdc4.svg?style=for-the-badge)](https://github.com/Delgan/loguru) + +Run your coding agents with free, paid, or local models. Choose and validate providers from one local Admin UI. + +[Quick Start](#quick-start) · [Providers](#choose-a-provider) · [Clients](#connect-your-client) · [Integrations](#optional-integrations) · [Manage](#manage-your-installation) + +
+ +
+ Free Claude Code in action +

Claude Code running through the Free Claude Code proxy.

+
+ +
+ Codex CLI in action through Free Claude Code +

Codex CLI using the local FCC Responses provider.

+
+ + + +
+ Claude Code model picker showing gateway models +

Claude Code native /model picker with FCC gateway models.

+
+ +
+ Codex model picker showing generated FCC model catalog +

Codex native /model picker with the generated FCC catalog.

+
+ +## Star History + +
+ + + + + Star History Chart + + +
+ +## What You Get + +- Launch Claude Code with `fcc-claude`, Codex with `fcc-codex`, or Pi with `fcc-pi`. +- Switch among 24 cloud and local providers from the Admin UI. +- Use each coding agent's native model picker. +- Route Opus, Sonnet, Haiku, and fallback traffic to different models. +- Keep streaming, tool use, and reasoning support across compatible models. +- Connect Claude Code and Codex in VS Code or Claude Code through JetBrains ACP. +- Optionally run Claude Code sessions through Discord or Telegram with voice-note transcription. +- Protect the local proxy with optional token authentication. + +## Quick Start + + + +### 1. Install Or Update + +macOS/Linux: + +```bash +curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh" | sh +``` + +Windows PowerShell: + +```powershell +& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1"))) +``` + +Re-run the same command whenever you want to update. You can review the installers before running them: [install.sh](scripts/install.sh) and [install.ps1](scripts/install.ps1). + +### 2. Start The Server + +```bash +fcc-server +``` + +To print the installed Free Claude Code version without starting the server, +run `fcc-server --version`. + +Keep this process running. The startup log shows the Admin UI address: + +```text +INFO: Admin UI: http://127.0.0.1:8082/admin (local-only) +``` + +Use the port shown in your terminal if it differs from `8082`. + + + +### 3. Configure NVIDIA NIM + +1. Create an API key at [build.nvidia.com/settings/api-keys](https://build.nvidia.com/settings/api-keys). +2. Open the Admin UI URL from the server log. +3. Paste the key into `NVIDIA_NIM_API_KEY`. +4. Leave `MODEL` on the default `nvidia_nim/nvidia/nemotron-3-super-120b-a12b`, or select another model. +5. Click **Validate**, then **Apply**. + +
+ Local admin UI for proxy settings +
+ +### 4. Run Your Coding Agent + +Claude Code: + +```bash +fcc-claude +``` + +Codex: + +```bash +fcc-codex +``` + +Pi: + +```bash +fcc-pi +``` + +All three launchers use the current Admin UI settings. Use the agent's model picker to choose from the models FCC exposes. Normal CLI arguments still work, for example: + +```bash +fcc-codex exec "hello" +``` + +`fcc-pi` registers FCC only for that Pi process; your existing Pi settings, sessions, credentials, and extensions remain unchanged. + +## Choose A Provider + +Enter the listed setting in the Admin UI, set `MODEL` to a provider-prefixed model ID, then click **Validate** and **Apply**. Provider names link to their key, model, or setup pages. + +| Provider | Admin UI setting | Example `MODEL` | +| --- | --- | --- | +| [NVIDIA NIM](https://build.nvidia.com/settings/api-keys) | `NVIDIA_NIM_API_KEY` | `nvidia_nim/nvidia/nemotron-3-super-120b-a12b` | +| [OpenRouter](https://openrouter.ai/keys) | `OPENROUTER_API_KEY` | `open_router/openrouter/free` | +| [Google AI Studio (Gemini)](https://aistudio.google.com/apikey) | `GEMINI_API_KEY` | `gemini/models/gemini-3.1-flash-lite` | +| [DeepSeek](https://platform.deepseek.com/api_keys) | `DEEPSEEK_API_KEY` | `deepseek/deepseek-chat` | +| [Mistral La Plateforme](https://console.mistral.ai/) | `MISTRAL_API_KEY` | `mistral/devstral-small-latest` | +| [Mistral Codestral](https://console.mistral.ai/) | `CODESTRAL_API_KEY` | `mistral_codestral/codestral-latest` | +| [OpenCode Zen](https://opencode.ai/auth) | `OPENCODE_API_KEY` | `opencode/gpt-5.3-codex` | +| [OpenCode Go](https://opencode.ai/auth) | `OPENCODE_API_KEY` | `opencode_go/minimax-m2.7` | +| [Vercel AI Gateway](https://vercel.com/docs/ai-gateway/models-and-providers) | `AI_GATEWAY_API_KEY` | `vercel/openai/gpt-5.5` | +| [Hugging Face Inference Providers](https://huggingface.co/settings/tokens) | `HUGGINGFACE_API_KEY` | `huggingface/Qwen/Qwen3-Coder-480B-A35B-Instruct:fastest` | +| [Cohere](https://dashboard.cohere.com/api-keys) | `COHERE_API_KEY` | `cohere/command-a-plus-05-2026` | +| [GitHub Models](https://github.com/marketplace?type=models) | `GITHUB_MODELS_TOKEN` | `github_models/openai/gpt-4.1` | +| [Wafer](https://wafer.ai/) | `WAFER_API_KEY` | `wafer/DeepSeek-V4-Pro` | +| [Kimi](https://platform.moonshot.ai/console/api-keys) | `KIMI_API_KEY` | `kimi/kimi-k2.5` | +| [MiniMax](https://platform.minimax.io/user-center/basic-information/interface-key) | `MINIMAX_API_KEY` | `minimax/MiniMax-M3` | +| [Cerebras Inference](https://cloud.cerebras.ai/) | `CEREBRAS_API_KEY` | `cerebras/gpt-oss-120b` | +| [Groq](https://console.groq.com/keys) | `GROQ_API_KEY` | `groq/llama-3.3-70b-versatile` | +| [SambaNova](https://cloud.sambanova.ai/apis) | `SAMBANOVA_API_KEY` | `sambanova/Meta-Llama-3.3-70B-Instruct` | +| [Fireworks AI](https://fireworks.ai/account/api-keys) | `FIREWORKS_API_KEY` | `fireworks/accounts/fireworks/models/llama-v3p3-70b-instruct` | +| [Cloudflare Workers AI](https://developers.cloudflare.com/workers-ai/) | `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` | `cloudflare/@cf/moonshotai/kimi-k2.6` | +| [Z.ai](https://z.ai/manage-apikey/apikey-list) | `ZAI_API_KEY` | `zai/glm-5.2` | +| [LM Studio](https://lmstudio.ai/) | `LM_STUDIO_BASE_URL` | `lmstudio/` | +| [llama.cpp](https://github.com/ggml-org/llama.cpp) | `LLAMACPP_BASE_URL` | `llamacpp/` | +| [Ollama](https://ollama.com/) | `OLLAMA_BASE_URL` | `ollama/` | + +Important provider notes: + +- Mistral Codestral uses a separate key from Mistral La Plateforme. +- OpenCode Zen and OpenCode Go share `OPENCODE_API_KEY` but use different model prefixes. +- Cloudflare requires both its API token and account ID. +- Prefer tool-capable models for coding agents. Local models also need enough context for the agent's system prompt and tool definitions. + +
+Local provider setup + +### LM Studio + +Start LM Studio's local server, load a tool-capable model, and use the model identifier shown by LM Studio with the `lmstudio/` prefix. The default URL is `http://localhost:1234/v1`. + +### llama.cpp + +Start `llama-server` with its OpenAI-compatible Chat Completions API and enough context for the model. Use the local model ID with the `llamacpp/` prefix. `LLAMACPP_BASE_URL` defaults to `http://localhost:8080/v1`; FCC accepts either the server root or an explicit `/v1` suffix. + +### Ollama + +```bash +ollama pull llama3.1 +ollama serve +``` + +Use the tag shown by `ollama list` with the `ollama/` prefix. `OLLAMA_BASE_URL` defaults to `http://localhost:11434`; FCC accepts either the root URL or an explicit `/v1` suffix. + +
+ +### Optional Model-Tier Routing + +`MODEL` is the fallback for every request. Set `MODEL_OPUS`, `MODEL_SONNET`, or `MODEL_HAIKU` to override individual Claude Code tiers; leave a tier blank to inherit `MODEL`. + +For example, route Opus to `nvidia_nim/moonshotai/kimi-k2.6`, Sonnet to `open_router/openrouter/free`, Haiku to `lmstudio/qwen3.5-coder`, and keep `MODEL` on `zai/glm-5.2`. + + + +## Connect Your Client + +For terminal use, start `fcc-server`, then run `fcc-claude`, `fcc-codex`, or `fcc-pi`. Use the guides below for editor integrations. + +
+Claude Code in VS Code + +Install the [Claude Code extension](https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code). Open VS Code's user settings as JSON and add: + +```json +"claudeCode.disableLoginPrompt": true, +"claudeCode.environmentVariables": [ + { "name": "ANTHROPIC_BASE_URL", "value": "http://localhost:8082" }, + { "name": "ANTHROPIC_AUTH_TOKEN", "value": "freecc" }, + { "name": "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "value": "1" }, + { "name": "CLAUDE_CODE_AUTO_COMPACT_WINDOW", "value": "190000" }, + { "name": "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "value": "1" } +] +``` + +Match the port and authentication token to the Admin UI, then reload the extension. + +
+ +
+Codex in VS Code + +Install the [Codex extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt). Create or edit `~/.codex/config.toml` (`%USERPROFILE%\.codex\config.toml` on Windows): + +```toml +model_provider = "fcc" +model = "nvidia_nim/nvidia/nemotron-3-super-120b-a12b" + +[model_providers.fcc] +name = "Free Claude Code" +base_url = "http://127.0.0.1:8082/v1" +env_key = "FCC_CODEX_API_KEY" +wire_api = "responses" +``` + +Store the Admin UI authentication token in `~/.codex/auth.json` or its Windows equivalent: + +```json +{ + "FCC_CODEX_API_KEY": "freecc" +} +``` + +Match `model`, the port, and the token to the Admin UI, then restart VS Code. For WSL-backed Codex, edit the files inside WSL. + +
+ +
+Claude Code in JetBrains ACP + +Edit the installed Claude ACP configuration: + +- Windows: `C:\Users\%USERNAME%\AppData\Roaming\JetBrains\acp-agents\installed.json` +- Linux/macOS: `~/.jetbrains/acp.json` + +Set the environment for `acp.registry.claude-acp`: + +```json +"env": { + "ANTHROPIC_BASE_URL": "http://localhost:8082", + "ANTHROPIC_AUTH_TOKEN": "freecc", + "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY": "1", + "CLAUDE_CODE_AUTO_COMPACT_WINDOW": "190000", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" +} +``` + +Match the port and token to the Admin UI, then restart the IDE. + +
+ +
+Claude Code still asks you to log in + +If Claude Code asks you to log in after you configure the FCC URL and token, open its state file: + +- Windows: `%USERPROFILE%\.claude.json` +- macOS/Linux/WSL: `~/.claude.json` + +Merge this property into the existing JSON without removing its other fields: + +```json +"hasCompletedOnboarding": true +``` + +If the file does not exist, create it with a complete JSON object: + +```json +{ + "hasCompletedOnboarding": true +} +``` + +Restart Claude Code or the IDE after saving the file. + +
+ + + +## Optional Integrations + +Configure integrations from **Admin UI → Messaging**, then click **Validate** and **Apply**. + +
+ Admin UI Messaging view with bot and voice settings +
+ +
+Discord bot + +1. Create a bot in the [Discord Developer Portal](https://discord.com/developers/applications). +2. Enable **Message Content Intent** and invite it with read, send, + message-history, and **Manage Messages** permissions so `/clear` can remove + user prompts. +3. Set **Messaging Platform** to **discord**. +4. Enter **Discord Bot Token**, **Allowed Discord Channels**, and an absolute **Allowed Directory**. +5. Apply the settings and restart the server if requested. + +
+ +
+Telegram bot + +1. Create a bot with [@BotFather](https://t.me/BotFather). +2. Get your numeric user ID from [@userinfobot](https://t.me/userinfobot). + In groups, grant the bot permission to delete messages. +3. Set **Messaging Platform** to **telegram**. +4. Enter **Telegram Bot Token**, **Allowed Telegram User ID**, and an absolute **Allowed Directory**. +5. Apply the settings and restart the server if requested. + +
+ +### Messaging commands + +| Usage | Behavior | +| --- | --- | +| `/stats` | Show session state. | +| Standalone `/stop` | Cancel all work. | +| Reply with `/stop` | Cancel only the selected request while other queued requests continue. | +| Standalone `/clear` | Reset all FCC state and remove every tracked message in that chat, including user prompts, voice notes, FCC replies, Telegram's online notice, and the clear command itself. | +| Reply with `/clear` | Delete the selected message and its literal platform reply subtree while preserving its ancestors and siblings. | + +
+Voice notes + +Re-run the installer with the voice backend you need. + +macOS/Linux: + +```bash +# NVIDIA NIM transcription +curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh" | sh -s -- --voice-nim + +# Local Whisper on CPU or CUDA +curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh" | sh -s -- --voice-local + +# Both backends +curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh" | sh -s -- --voice-all + +# Local Whisper with the CUDA 13.0 PyTorch backend +curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.sh" | sh -s -- --voice-local --torch-backend cu130 +``` + +Windows PowerShell: + +```powershell +# NVIDIA NIM transcription +& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1"))) -VoiceNim + +# Local Whisper on CPU or CUDA +& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1"))) -VoiceLocal + +# Both backends +& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1"))) -VoiceAll + +# Local Whisper with the CUDA 13.0 PyTorch backend +& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/install.ps1"))) -VoiceLocal -TorchBackend cu130 +``` + +Restart `fcc-server`. In **Admin UI → Messaging → Voice**, enable voice notes, select `cpu`, `cuda`, or `nvidia_nim`, and choose the Whisper model. Local gated models need `HUGGINGFACE_API_KEY`; NVIDIA NIM transcription needs `NVIDIA_NIM_API_KEY`. + +
+ +## Manage Your Installation + +### Update + +Re-run the matching command from [Install Or Update](#install). + +### Uninstall + +Stop every running FCC command first. The uninstaller removes the FCC uv tool, verifies every FCC command is gone, and then deletes `~/.fcc/`. It leaves uv, Python, Claude Code, Codex, Pi, and shared PATH entries intact. + +macOS/Linux: + +```bash +curl -fsSL "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/uninstall.sh" | sh +``` + +Windows PowerShell: + +```powershell +& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/Alishahryar1/free-claude-code/main/scripts/uninstall.ps1"))) +``` + +## Project Links + +- [Report bugs or request features](https://github.com/Alishahryar1/free-claude-code/issues) +- [Architecture and extension guide](ARCHITECTURE.md) +- [Contributing guide](CONTRIBUTING.md) + +## License + +MIT License. See [LICENSE](LICENSE) for details. diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..205924a --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`Alishahryar1/free-claude-code` +- 原始仓库:https://github.com/Alishahryar1/free-claude-code +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/assets/admin-messaging.png b/assets/admin-messaging.png new file mode 100644 index 0000000000000000000000000000000000000000..5bcc5615d3110fef77ef97ce99553091c06eccb0 GIT binary patch literal 144665 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

=Bs+ry-9PDnA`?xetI$YV}9pw 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

x>21Q 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 z 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%0DK?$KHf!@dT$hS+DBM(sWK= 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 WI_R6E?*9lV>+6_2sMmV_=Klb=)r7SG literal 0 HcmV?d00001 diff --git a/assets/cc-model-picker.png b/assets/cc-model-picker.png new file mode 100644 index 0000000000000000000000000000000000000000..b5ad7aa6724eef0a735f5b36b48565d8063e816e GIT binary patch literal 154762 zcmdqJbyQXB8#THS1q-kcBn72gM5I*^q@^UJB&EAkK`8-IN=XSRDd`3&X{Ebl(+!)B z?^!22{>FFzyW{?I_c)FNo5fyhz3&rq&Sx(C9?OW~oh3PoLZR@)ABo7LP&j2M6!!bm zIPea^t>P8^nDa6H~8Gavs3W>8OukiHYn6p8ss0Axctpk z6zVuxT;#r@qvqm>i@KuSk|oGn|$6i52C`~sCXZCq>^7D5|`0mAtxZ=(kv3Fouq6KE3V=ru{@SU2`{r^(uPTVH+P z#I|-4pbXxyJaBC{Z0CvF;Nh%witCv9ML3vJl_slVfx2h5WY^uJj(hQsmz>Esc`x4; z?f3xSKVE877Hn*6k@E}JW~u*pB|K>)MH!o9`^|~``C^H6aC0M{Uzo46{o`QjgsUAx ziXHce{y5vk0+~?^2BSmr=X-J))v+3XeqOh+QhI^b=9s^*hgm3g z#xTASy9^i_kK!OO<$j~`m5i+pQ^F`JSXGxXPlY# zm|C`~18Red7ejFEmWQv^)YPo5tzFFfeW>psVerW^DcZ}dT9t3|3_HH*-KuG6kwiX> z|2up=G(<{8MRof8`KcM!-#1b4bX4bM#{9y9)zN_y@p&B7IG!?fg26Z%ed>LaH>Y9y z__tT*#t#C1-(9XWJMdkJ$UH)Cr{3EiLC}I^r79EfLpNe=lN!y|_(Ii{7mX zxaYeL%VgNFOx*n?HZzYE6ly*HTi61=k6s$Eu5e6rq!b+E*`)VvAkR=vON-5*KwP8D z{$XF1M!nwe4>tC>eX1-;Q=_M+N9XFrizCV@ z8cNE3FHvg6&ns~8_7^Y8wa9B|v}ylwAxx~e$l}e<&kwehvx|siifd?)!#@4bP1Q2d z(l#XXCBPM@W@R;W2pu!ShWx~vMx$T-`0;pWf4>%%yw#ku)PCtKicCJ@-&23=#wOtR ztPdbcd|G6#6RttTs$QLuUFk0?xGU0Xe7SOOp2_w2a63}Kr7%*z?=>DNyW^_%uvH_u zPPo|fv-TPl&V(tlaaUotQ%}wIWDuP=abkRF$$DnwiB}Elzl&px&u70_U0~8@Is1c( zqa|y0_BmY9ujR6pt9gUoc%*l()6(MXZ?8Boj-%)I)Y$tUigY(@E)HBK=d;(#`JG1J zrIVAB=@}Wn!PeI^mp#c+_uJWVtZQiSS2cT`hsA8zXQMn+aarQ`n!^_q+%8+Di(^%k z92^||InTTz@owwJpX2^&sJu;4VcA!At=MQMCY2kD)`_DkmHUJCtqBfYxlU*X2={BZbnoJ>q3ECX6Qyh#yP!HrzXZW_*D3_*pfpusY~ zVBC6~opE0SzBDmuH7elpy(Wlb*AubYmgV6e)tR(4dQ`kM`8<+$&sSAuL(u9P7DHUk zew{u?fNyHk@T|7-^XK%vnNNN8SE{sH*NJg{2Usc(dweZ{Axs*7|-F1ESO4n;bhnttro;%0ky!jmUw(I@7cXgA}=D!*O zi(PkbRqikIakQ`?x1^~_fln#OOgT2!l_ICEzWDQ(Y5HNx)=Zt><vKe` zw@n}d8T$#ptem3sAZOJm*Sl7lLowvw7PwTlMRl{7bh_R3!sW)`hZRlUdwXgaYTR;? zaGBC}xMRYZj+jf_J$ea;E>xXWxDB|XeyShMhKny|R~_++29f!BdZI!jA`;8X$*{1n zh;Ki?V81fTa@FXvrt=()=}0Nz+S=ObbLZX%mz9>12>akaG%`w4@t%Fn=YCMkFBMne z8F6Kn*J=G`Lm(;LA9q9_K*ZJnS*M|Sl}(emlCXf2^p5FUY>kmpyIQCTbxI$*@y^)W zotejfAHl||UVQp}8U?Ka`O(IqoL=U@n-2Tshbq|@T4O$VuIZX9Y|o|_v<@g`9C+ZB z;=HO>RQ5S%=y8=B&5;}u@T?Tw(SJcux%C7}@%%2O&u*a8_J{+A)uafjT8z@oJ66C& z2lANsjwvw&nP0AMqyNJ}Z=Yl(W~O~QH(bK0qoY&zUjhI6xc#RcoU28cIn~}myqlLyIe*N+=Yy7klJn^{(du^CA_*Z4wN)E@HPc*Yi%3h^|B(uDBFM@*! z(z7)$!X0JD?w)MeTX0MmT8iwkD~@(ApXNUrwGiCfv9I;}c+PQdhF^)taC2#hh(KWT z0_r*)9j>g9(>ofJXNAkQ&Tz5SlSYQ4xYXoa!d0F447AHaW>Lp1B~ypa7X@JE`pv)0&k{%#r^I%8ICsa@*>MTKO>nKv|&;rCLyKBcFhg&tHMxfB|q z>3pWXzCL|73o=hhKssqR{f_x?Dx(RT03_&`+jh)BEd2b(oNc<@&92YV3TDTv6Sa87-@-z6)g+JhS+ zpi`YuakBJ*u<)AJF-Gf2%a2dOSk;UgYVb~dk+D5o5kC?%JsHYF=N(^pduwCndu9jn z=we-#c{rl}ZHcKPd4#j?ZaY+G40vhO>}k%9_g#!&Rlki}%v@0lkWBk)YYV1nE5^~o zhT^4%z>@YZ0L1-{Pm>IpLx&gz$#*)7@3%2NYX7~efmr7 zn2?GvKW~gw1o$Ax_xiyClaSERo7oG^N0Jqm$j=p0WS$Kbnjv+JRwmlFoWIXyUT)v^ zV8`kvEiE-O^ZEPt??-Z5#6nEwnUC8y2b}gt)+a~D zl<#w|#7#0XLg;E{Nn4HZ7?{i3Q<7}gTOhKA3VrX#P_q_=Uukdc6q z=Ep!%Zk@?Gzb8YvVeUJt&%9r~YN|c=;NlesYGEfQ9w>r1^jpV?!GiC6w4?8sk@RM1 z1T;5GoWFKUWHkI;z6RQbhuTc6L>BcJVi>kM0rul?4}+~f>|rE?Pr*-*I}U9$AcoI@ z`tDs~q)z!LB#AQkur7|2aihkMpruopr5JX^&i3ctD6fb*bi%3bOk{$hmz$fb6W(aM zG)RcDT$_Bz(Lz%+>df>tFVAXk(}*9_t$?cjk1#aZFZRDM>C2jc)Bv>i^XJdi?d`@9 zhcOm|f-~(WOGDK)v;qvOE|U!3xN=n5UTnSlHC_92{VF82S1R{q2;YziIOiLWG$1wc*|TS;`1u%X zh{c1AxcTZ4r~&9$qvn!^Leqg0sP+B3m#$p7NvkSnF1E;V<4_1z4*Ivf(J_{Gcd8u9 z6SL6QV$>K+*|@tggLQ8?`GH>WU9pI}K12oTIs|MurT=F9qd5R5ud1qqAje`f4-OC4 z<}l!D^0f;tGw&F z!#>>+$5zz{jd03U*+rS&i%%yzbbZ~r=QwPIr7e(#C6IxU@x+z;jAEff7q`WCkNf*o zBDN^Qm$0j$sALU#WX^aE5^?-EMQY>7G2Zi)0UKK5zq~7?lP=EnW!Xw4DTI{_o5PqJ zx8Uy7<+aSgH+;**wR86(^bjobD~PX7FW9J8TUkuwx|F6M`;m@!TGmwz#%^|)0aX>t zN$%mwbE;_cQeYmDd(C{N)-dcU(wpGErSP4-C#FPf!2|fp5Hw`>iB_mS#pM;05 zUb!Yes5jS^UZV|*ex)ZnDEF3-?@SC{d((+`Z!LbNUPopi-{`XTnbPw8sfK7#PHaNO1<;5(F_X@Z_X|<8#1uTHS9@OVkZeic>)~_ z7+`E(@_m7gK0d}|dz9oKqaG{i$C^lgw2)E{9io;03gw@DyrK}k|iThEjezTf!mdC`^~jG;*dMkxSt?Rr#62kiapIN zbJ;fMQOP&zqHX=iC8_DYT{br1P_-Xp>=x2pW&)jto|Ux$emqs4lyuV+u;zurJo_az zCvx<0OG}FtBqxNI|EMPPsr17*E|aSWF@wAgc<4)pQ+iXUIzX-PhFH+}_ltc#@xOaQ z0%^tvFO5&Dc(dM25v(+yc4{NxLKcYwYh3YARz0Uta*d5P~{DSiIk6wHxG6UnfXhuu^kRy zP`9cVCJ^w3T8yd&0Ke8m7dO6+ZrRzcIu7jaR&0$G7&7m;z@UAFk}|lwoPP?9rea}{ zR>xbZ*rw2)yI<(AqUj2#5b3tSZR?cQf;|qfbINA`rg=lb!jE%Z_iM9D9(_JAFhI}D zTn8J1)G~l=%{7cJ25lc-!Ivk(J^P^ZqMx1A@;J1UioPp{Kb)MDRQ>$6&eZpib-}~U zE?Ht8>zkm!gtKZeR%J8oA9qT!)jYpF37h;7I2K?gU%0#jIB;jRmSCxuufW1>Z}UFj z_mO)MB4-^B;ayY7|ERBYysDQ*B^GaYRJkQ)o(-b3zOoZrrC?%Zr>%24q;OM~{&1bL z*naK`(9Rsg4$W5Qd52lIg&Xkiig$ycw`LK)FGH}vD>esrd=ymm2Z+?w8|ib!RDzUk zx%TOd|EfY@iNjAN1-v(yJ~=fr{KrcNXkW%LC8XlMr4oMo{=F_&wxW|$30ZlrKIGrd zY+~?Z4UIq`?INP0)HE~?)$z(4RzwX9XaTl->YBSiLgJs0K+U7_c@ysQ7j)AHN=k$; zy}T~M51>yEs5=aN)pwkDMP#`-EBA(s=h9_5nL2>`Z#<6o$M9W}#9{S)h%_Csa0w_v zUG%W->Fd+@`}WIW-ZR7QyYV`T@8*tm?W zLL5v%pPcejU=a&|f3T#cNAP}}qBfXv?C#MNQ=OBsQZV1w-}k8L{wXG9uj+GX_+zc_ z)L+uW9DrC}`^D21D+X?{pH%IBgMr`LiSOVC13de{81~B!Z=zlT0;o~6c-f?fLxTOc>l_36M;-@f zE1$LibxNSE{Zpx;Ya>}SHZ%S#^2z+UZ{*_bF*EdRPRGWJyVTTT#EbueLF$ApZ;*x( z?!5j_Afh&w9bAri{QD62!g%A4UxDuvo&@}HJaRz6|N8^f@$ElI(RTOr_%(@$bU%PE zYV>aTHJLSoAZCStsSm%mbdXBAChOxz667s4->nP(-4FG<;wy{k+1Wmz5Q4Z<#|+m- zm7bCD?A8b3KZVDdxxWNJMAKXv7}x*t!<*igJg;m z1qB6nUACBK;C#jtuCA^ZE?s(wPtJ>|o0^)MlZ>gEnS}3CukG&a_=^9|{-pbfHCd&l zyy{i%QT{DLX;mqe9$QrLr+az zXJnL+5)~E2IeYeOo!*CD>wnh}`M>l-g&Za-Dl8)-Bb{5QYHjv-P-B4TE!{yS^ewDK zjUC`1_r+j{{EqD3zJ0quK@qgLXlgo` zFRre{X+-KUY~|xx-u4s!cU=1V4xR`m&`s@I;4to{PfAXv5)ja6HQz``Ow0wkR@d0r z@`Cj==BqVYLP^smO2|VHK<-6oc5mLC=NMwmM1Pdj)QpXs3WsL+A!llGpp4p6tViuhIhxd>-QX&#x^H?qBQhcR z{j#oJ(vD!k@xlU46NTb={$n&+$U{@=1WM1q;MNZ`6#@UM=fR+YFflDHZFB9J@X%0g zI0FTqx!>2XU-@~D|5N)f%DHsitYN*~*(I9YcLUfR=xp~O{Gf^DIBggpKea8xbvoJp`lu{C%F8~P_ z3k3u_QR(BAoeM5I^hL_@^3)b=+}uP^ML}HzNS%HQBF#5ASPZfs1ya|} z;!~$i-SKfV^`|Wp#Bj&@NG* z`U1O$PNSNen{zBD9zcya1D~c+Z276&Tv0L9c&7s56fRLF28H_c=~HTY`pX*aw_SC1 zRHkf@2~eKaw;=viDjJ-H0*N_Jz?;^xd0ZyF-<_IcH6Wtlj$z{Rt}FC@c0-}f z@-Ponn5O$>FJGQ0+lX^o-%*SDO#JQffbMGHkh$=^dnhOaA#riR2#TA%3k4~~E8>sZ z*qsj*0~*eCDysXMon2kIdQF#RZF#FNd54^*6jXPwaND=vEtqOys|wX(^5TRMnrE^Y z5$Yr^F0h?MxPx~y4eyQw?B+w&Wl99}EhXgD)tR@y$!D1aT(Ok*hh2dTy~W9?GzXVH zH#hf4LPGwOIK)*BoXF=ziWe?{zqmbe+3%6d?#|T}gB`(!9YxaJ*}0UA-y1#rTgitB zTHuU*j>+EU+#^ZJ_YnYGd43eQA39&Ta^?GigfRYzYG7AWeebYw@xNz>RU`vcJfq@l zEA9lR7Yn8cE21fJ#-typl~lAvaVMsxem4$rmh=;-6A=p{yZD~O4#r_gDJilkXkbL{`{I;{{Y2lxIE9Ez6KS8kQ>+5^!n{)lnN^0uTHCRx; z^Eh7oMPZqvqG|{0Eo|#1Y0BMzP1j%5{cch|o=)N{KXQI(xNoCb2OT>7?(!5UL)6n}$=x8em9oYwe+>|ke%}jeep#QTL7Iuy;P+OT`kV02QA>>}p zNPt={-;mk0gCfpI5I%7Z_dGEw@#kr;n(I|@S5RJ$v*P3L#j-K+@ThX#)=~JefU)g(#G!P4vHWnGKC_|`9F^DX!}&(1Cy;=f284eCx8aCqZND)9z0N-BR@De=u~2mfdi^xrBJ9c`z32< zL8io?zEr}m<7MLnPoFtclUX$C4~usl>R*T8{+HpQv@c)I1Ivt6U;Z9)0~A&Y)cA0T zO|knS@6m3D$2Z`HqvcMsoyq6mB#-6fUSvKkYDS}wkMn)=Mg&H897dft5h8W{Itn%r z&VKK~gA>4(%qYAv8lCzy9H^jP{%Q9p)o$A(xLR6T#DSuq zCbzs`M6Iup^H`tMa^Fp!)Of+6+i?BXtxKpJp!rI%e2us*JWi`MxIfbr=r(76VuK)j z8XsR&MC2r*&$q|$4KZgFM3>3S%a1QC1b`MXk=G%3InPW&TDrcLKSiJHbnF_|jIwS@GF3;%^|0XK~Buu+PNisLhJ9^qXTC$O=l{V~z+-oEwB&%a%L z>&>(K_g@tIYdBX>Fg^M57}Fr1o|<}3Lc+gCU8>phzPPww`C)W4naIP3SmDx*zwO|_HG>LQK|z7z*|(FZd#8Qc+Z+{oM8uuOTuUEGa3WXJIKAlzI5@l!S!D+tAP{ z_QwgY${Hg87XdC)QiVwU`t_DiEsHTrN?0$|d7-`88xR}tUq-A|!sMh5vs(W5bXGhZ zudbPnu%-Xj{$hv;uibpoUe)LS^_z&aiO&xxfe!NR+XJ480hj+F3>Tfw{x8YsHvWT# zAnhXa>i-u9ps64NVALTDfJ$CLq2AQ`;zC!RN}R||2`MR&zfsl&z*c_@K1t5oJNedV zSA*J$LfsI3b0M$kA$NkHjkWa@X;m`~#Hf0K9DFK3WCZm_qAzw<@{ksnUc?LxU%GT@ znAfnI|L7g&X1mh#xVnB&WDn~c#+g|q2j%DIr}N}}6#KIi2tGdPgHL`bPwCR-%b9{V zxGhFoudCju<2A{QlYNXuz6Ew$^ivf^=E7Mb}j zB;*B;%``%U!m|1nNb%nN4hk+@=^9ospf{p{B(ER=p~D3X_yz?v!1x;=QubMPhL%kA zlFl17bmBTX)QDl>%w<}YkuL%M{@*s|x^e+b78N-`Z-huj`O{5{mrZANfP{ZTP>=$e z(f9A)FXU-lqM%@{q5y)zxFWh{>-O5mr={;p8Ky@bZU@Gwjmg^AL-=04d^yzzGMOBj zD|zi#BQ}DS9s^`BXo00h_!80{Hn)RGjW@qo<0#CRcq4w58pyk23z~R(X zdZkN!7*B-6bT>0)O-ZTn5-AKegk| zzxBj;3=jg;c9F_8rN@swTU(`CWB8OS&j^B6-m}+vd6I^P1_3uM04|<2ytxJ?rm-_w zl3dVe*&k*Bmr&ZbNkZVxVIvV1S}DivapXERGjjvvJN&|uL}<78@zNh|J#3{VD(Kx) z&ojUSg2@4{o8WvVS*s$!us&yJXWr@d7}(yk)z)SvXW~O_AUAS=k!IkYPPH2XRUer= z0zhq)7brk}$_DGvy#Y z-=C|Cd;R>(XIrp~ahyTD{qSL~8&Aj(Mwrdq^Drhg*#4|(VDMSYV$y7^l00Ygl3!5J zRY>M9vK{MsZW0v&N#liduf=Ze>*(sjB=Zg6HK1QTOB-UD@4K;cfpzFSNkqtoj|@QE zdeboX!@Z{LU^1SB<{Q74TD%--JvutNaC>jV!vR@vE+;^ z0-63CfV9&D1Q9-_p?3#;PMy0t9>ll&*6SVMPLSbMip1^jZ>*o*Lj&^6B2-iIn z6VqLrtbamq0b1E4z*k$x%8>%g`zI)*h+k8Axg;vkd7!GV)zXl$^7$P%9q?5iO{c1y zoIAE4p_MJgJKTSbLb>z1UuDWW%T8;~{#m+&9Q8w>m2(VBZC;_=K;w|V%X;)vzw%R4 zx60bDEa-jll{J8=&HKQp&9>F#C1hoRK$MS|D5d3^NHmS?c{g;NDVCGT0QNlD&bK0> zr>uH?VbDEj^ z3wA>v=l^j|re=cHd<_+!lXC&QN!HHqHy2NMj#jt?h@(6a4jfL`1-<2?ZGRrXYE(6W z(7|uVu2(*Nfm5J9s#cgmFET{V>9D*6bitZ&Lt?m zcTD>c$dX|myMS?iL&9|yrrQ>_zF!a!C?<9T@G*u0qE7`FhwOF;0*FvK zIk`wdH(sdAeY3e-L=Foy^z`4;k7{dcAu$#$i}{on@;Jskjs>*S4AJ{WW`@hX0W8f+ zux07%`JD?G6~J-OSG4V1FVjj@`x7!L_PwB|rv5g)8wGT>hwM2tyKjpFdEi6v2A;V4 z*;HYBYp`^!Xy-6Mf&HKt%R2rX%6YbIJc~nkbxo|}na($Mr4Iz7`*^X^m0I|=Soe-A zsT?;jbn$%-a+wR_S=}k$!#X$}I?50=AQv*{3v`$H{jb27&YU>|?V>teDT72{8yaQN zm^uD!iHhtf{|rnT+U9`?j{}tb>pVQyU@g)+l0Www*mwjd11AGW`lL@*P3>cC$55{| zMZRWHW}4aoA*fUg60mcF|A&YU%x-$}mJ+VAT@nnH@A z(-w@BnuN;)Hh&1~6gKi7?fhB~jD`CK;C%yxvw}ypy$mdB`H!;VKp!YkH#cX6DG=yp z0H+o8fy;tc$2NAmch1&v_LI+JdHI(xg@gUpg;r%9Yh8Kg@2%Tj8cK0q0Wb$W^Lr!h zsHt6qB6nwJ|El&S*g&vXLYYTcoU8d_TSUr~vD+O{r$Bli!LH8s@fXgyXeb{j=879h zqxhg9q;JqRb#y2v2kc%#uOaH(KEtW5Z?&~(Iy}=4f^Mbrdtgy9w#Cl}M+wM{p*g7`3(!D;Qerh=_Qw$&k`(MG0hvwEU3d~6i2-FoA% zyyegqDM)SaeO;8KMb?09)`RVwT}N$e+RA=A;#v#x>)8uNu)Tb7hRe|oxzYp1$`Zh5 zaGCEXAnm9>=YD$&h(54M{LIgE`_^GJGqoVGIEN$owj?$6k!ENGMOSC1q7#U(((-Xx zt}@SAQk0C1)7AT+PC~!)8*Q>z2mD(SU^Vby&7 zIJCM7^TRjd(WPe6mqjx8m7Vrg!A<(Mji>3+htTIXO8)qM`y| zGyZ};cdlL82p<8)MNvjYBR~2`TvoObh!d!m@oIGwZAmHq&q~tb!oz7JTAG``LGMLl zNcboLY~Dj|>`L%DPWpOe!D7n042%&wvEoV$pmw~%2-W8N}8c28zA_s=27w3fuInDP{2NgD_(%SQ1u>xy^j79 ztk12^m$SLZ{Yk@^TGV4bMmEW z+YSAdvd@2Sg-IIGKe%cA-v20UyP|dA%_ko{|Nix_cg{5QKGSO_=S07O0$=~d008h5 zm0c)zf`Acd(69pF8w4>nVrF?D=NUF!tl1Vb6zR8G7Ng|@^JnIv&X#N=66h70Oa_Zm zo&d&-rI_*k1Ry82f4SibrvhKz>5vvbaeC zRZ4Bs+M*KG=z~`vLDTtBfRsSe54%TxrS`$#LJT)>BZNO}Ot<}|+193jxU1$7?d8Y7#SZJ zK=Kuk8xeQFL?ViNDe7egX0Cb0yVn|!s1hDte?K)OgHupcXW$NnjeV+txCsxzvSv}6 z65Bt(I4NeRhBd?%8(fx6(_LQ*Vff;ddQZjqWB>Dbc~6BBnVG;ib1opFZQ z3t$}o0dpv6PufOqjC>~`AV9>_Cz)!mU@`8f@Zo~N`hUr<$SDTvx{I_S`+{Oj$B&?&85IMpO zxY!#?AgX$2?N_}iQ;@mJiE3~W*WJ9w;n4Bs8t2sY7q-HtFoa5vWp7SdN^a_1Vrl+# zTA(KEFu(r8p6ljMG1w?XkApsph{qc<9gQHuAdJTfbG2sVuZ1vu1ADquG=@*_tnf{E zNy?;xUMOPJd4Yw;4Me?k%+_8UiXWJtpg4Qdq(x3YI{D8g9Jhl=)tv5i}hi# zu~~DReIcEPjp)820?MRjo7cCapGj(G2o@MOZJwz;hh@rOY6qH#r_N0YaY z+{6q+2g@P7TP&CFj`X}RYnLrS2mU!xw_x$wEglf-a2>LT!ZrVmm8lmXv4U7o8+92* zJmFfBFBlyV)Kb9PzvNQD*_0)D7L9|a_(V%O77lye0&^c8zWyVl;O;efg+2z z1GNE@G@j>~M42QRvWRSOl|}95_DA{Uyp*92z0M5u^lyDkpb}fB<3@1`$1x81X5O2K zaTsN&Vue^mi;KP?rlP$M(=sT>k2ht)Zi}(8HX)@iLMsakb!lca@L~~{CLPLhYf&Fn zEiL@*2VJWx3nL!o^6>ui31jGzejCf_K%C2WCIdhxQ)(kGF=!(J9Ce+KZ^$@^THMw) z|6tX3eb9*E_j1Bi%VFnf1BG&>GXI*u=L6|el26|{@A$RH2@$9W-Z1MyLp!a%8BZ94 zw4MKI-3i{E-QDB>zd-Pc{HGNgYvQAd7qZIU0?!7SLKn=2VA2QL} z!7DqP?Tu(6zsP$&0EouM#_ZNpkE%PLGPAPcBkHQ9B|98#39>I@XC>w|I1i~-SA;^r z&~S3Xhq6JMd&K+baDPanBNnk^)!&HMhu0?i}}2^gx> zAT2Vb_LZ3=1QU7}Xl0zz{tlRlGNuF6poXV^>9zlytVvb?P)kDUuhp6{t+Vo6JwJs|aDT6__ zN9E<^mMttr-xmY$Zpsj6bTw9JVcLCep6o{lkN zwKgPCk(@g+cZ{Uc(aaKorh4RghkcJ+y(qW7llLZktrpQg0b>zbK94H|r) zt3RPFWM-up2NNn0YwNE%OE`!m;S5h&i5`G>rfK>X_J>iUN@0dyr|M#&1z4_KB-I7OtM%u#$6^ayG$k2z!j@=KW` zC&R>o8Bih3>v?@st>dqqb^x6SF>dh2w5KoeCL+Q`%x~eMq0mQE&>`kf(`v>=YF9Ny zkDb_ZeM-EC{b#dvP>>mL$aM$r%wIN&S6Qa`pI4ZZn`I>T{Hry))z;ki$65WS6i{R&N6`$^bmRp@v_Xa{`ULP|UiZeW|;nU1o63Wy6v z{}$QZTI-D&z`gwiCPbBog`XT=c0xuVPhoX;caOtd>>C7Yoe~KK8fLSsEiMj@+6Xbf z)#S;_-Dy&I#HD&{Oe>4jtHu+?tm6QEiw3Je1%t^Kh_6io8aL(LDvyu$RVthd7-iQ7 z4C5wX4D>oUIM_HU$8;O2N&2CwY2^(UvzifT;c{+A`(wwie?Aw<)~uLH=}&f+0zQTe z=N?IR(A}Mt&Nb|yIEjPvAiJ-{PWLtm+Bqah$w}a@u=CzfjI&ku<+`3&jps>%OXP8i zDJxaSf-pi~+^VxJh%w5HXb_BmOL0L83#1g(@LF*C==KOVkJ_DXsifewePl;Y5-7Sj zSa1%U{z#QguW(zdCjugxQ)+d#ewPmfxFfKb#%oo%TMq!$Ssbk(_1LMU^p@vtXc)e9 z;JQa`OMdtxH!*IQ=^V;^7fljGCJ@+V$U5nBLnxzpmUl1Fup?`>w};GeZ*51lJnZm{ z%%SW3kpIb>QK2UWB4r9g_xBdCXbdgD6&mBZfY!o`W2&kd(=`u|Hyo!pr zVFIbt7u@aj#Q0ocr7~HMq$Cf4TJ}({;=gcX>4lh!eF#vCUf>zEdsvh^*YFPq z5cuaD7YBa3@(?(0_OB_=>``?b)<}vDBQl+kYxy>oDKaJgf1B+heBgFXL2m}()lg*|M4T^3Llp?jC4aX zlBy-g8aeBkt*_7a`k_3T&@NUbrbUrv5t-IrL~hJ+xab5*zNhpfyd^fSrg31O)O0$9 zgY&VjuHoj*o7cg#B!$DwJ=5IMA_p}dFdYYJ&T#obn}xeqlttw=xFE}~(R_e45Ix#* z{2q)~n&trG{G)q=iy)ryS2`3IYKi8SLF6CWQbh~t-(tpdWg;tK?qG*+O6q<;Bg`qiXYsrj(m zx?PPob0)i>1w=`h@}N+N3I)1?vQZbm(`c9-!`JN*VT0HM_u>$kP$Hvc&l&aP%VD>KK?Nqs zi6*OR;Bj_VI~P@$l6i+?RdM)-&C!BW)fmF*zGmwuStl@Jo`W5WaSqv!tG7AgYgy)< z(}oDT%W)%R()t|oD;fqr?FHkC{8+x-AU>@Ms<^;jE~JLsWyuC?6&2aqb#T^jHL4md^VMWWV2d#(H@dyijnmsLrwRiX5IV zMs|J6E{Fur&6WCuGyY52_+77}EeXPO*@#~UeI5?|tip81_#>}_yeuBWUuS6BeZPa= zard$NV&}4#oA=X9<-`f+ht)0IsK=%&o5vMiCWb+N4^e?}hTJbR#ypk+^Q!1}$_m_$ z%n6N**tWLDdUtIKWp1`%NX`Em$-^}J%GImQa}+BBNQL>sa4BkQdmEW#{$&w5^=TME zBu>jPSFVGpCp+w8MwHS&!a8u>iLEV24y$- z`PJP8w5w4{qk)rid*@-;ldbBokdgfTFPoc8z~aNHxs5)xlc!EKW$$m!B{rA+{Fqqb zIA2Z&Gp`p}&^x|pRZ)PFgb3h(o$7nU$D3Ag6F!f-o%^vyUQJgy@fu6peaWqCH~6C` zdRnM*@P%DzQO?K30D^TUjHbqL{Y7_=6=! zM8rEi`U&7yL;^C7f(Js@@esK+*Y{}>1-ElWWe=W3_azvTwzjpMV&)DTne&@4TJB9* zS7QTL($rH|IPyNxv(~x~zS@`yD{Divm z$6~E!=QTGe71f#^ds@w+W?t**Qo`-7-p)(D``s%0rVPf}Dl!i)pPA)3jU|9vQzYpd ziGJH_&8-GIZOxXv;Zo>7U~j}CmMLjdu!yU=$JRqbo!lkeV5Zn3{cAzo(OAD^W@P#e4t0FOWxY%XHJ zgTKEaHW-#t{4}oFL;yxE0Gpt>pVBTVEEx5rn<;iVQcIMMy7LU4QANf)jg>@dqz)Y} zAYG8X9RizUz@8MfaR4$d{A=u9`SIQM68!CnZB!2%7VSodWp}wQMMFX%s(4E*-aH~* zrR--Pi>t&y1=k!*|(bGjG@wYKp9HWq&-j%yK`I zI$+AvV0RB=!Q7bBpNC;sR>nx)%hZ<;T4T_lQ6Bo>I3h7*v-LY*rc&JsZx&QV6TSbA zcmD1*R*`;MZo#z|O={BO0aH7u7BN%w%M=$s-eK8pZOJM8{5Ois=4m{MvTB zyN5=1DzyZa@YUz`8ouk3bae`L)U5~|nJd{~w!JTT6sbWjWFDH^rBvLNh7K6L;R69)m>3YEd|b z@wv|5NH+G1Y+8ixm0^3sNG|Ijx(><$isk6-Kb!{%pD&V<27t@VbhP{$ScO=%iqv|B zoGiEZ{I^_cQQ<-dSk;@@j}4v9c7Gtn>b#sNxb4ToqBWBBnl8D5P&mq#U3Lsh{gknw z>7$75@~%}yJKUL-#2VYPsa(gqDI^jPnZ`=+WY>E8OVUu1L*_w3sS8zv3SW%O4VP9+ z3;G0~CWQCiNZ^_$<5F8Dv^g3w(<#(YYq>aZ=||$Hs2^cB&hY<*e0Y}ZJZ0A8XuUD8b|E@1sM6CN+_JX@6%mae4C&z5k&34G zHr4>!xDUtyD9^5n_B$4%=PNYXwzX~QG&LB+YgN$TY2j2D5#R;Y0#wZh@V8WuktY=? zP0-de5I~WKUZ>)dK=y{`y^&TA1E#cIC7E&jdxAb78PrWRy+?0@fLITIr$SFjc6l*{ z33H}}$7QLYZ|aydP!usJ!W5&w*h)9CM%r+&_?-&3#8!Knh-3x*P%UWCiWqp(VS-A5H9WAq5%x+3j zatu~AC?-}jF%HzXZpoV)7B)L4R1^X8A))?uks{c*kV#~Z-4SSk*YDhUB3M;8NT=51^kZ+gQyp`OoIDU1f6Iad zLKx8zTD%ADy%O6F8*i^+gd*ce&hO-v;QCki`8=$w!f{=GENg1YdqREebT{RNz9+J_ zON7tespVOpp6NE{6lzNCUc0eD`Qo$0_qUexKMl63DJ$(gD1Riunt%hobPUQ zTljEz9Awc3mR0QMF1ie0td}Aav!|qVraJM5bRt3w%Stq4lmBeU#)^X8-kW!Ssdkc_i1|k`mf&1D&*9!TtB$|Irnh$ zwqbi3-B3#JuP7cH+6ND!DUX=>o(haf1BLm?(#!v#{)nj6p%;g3j~JTU!~-L&Adk1{ zYLqA)O}0}gbpXIzwX&xv1XW{OQl<~Y6_;9S=VVC>6Dj#fKfRBOI36Q563P#M|7hQd zaRtxOVbQc8jtKwiq+;x3h%@QS*RrB3?18Q1dG~L zAWcl@P>e17Zi@4Z-ECse*zH4-7${(N@-E3|m7BN6e`9n zyL#1*Q<8oDXmS>n6}l3JeRsLgghqLAhUIvJu-p~K z8g8sxZG8v{8V{NN$rW_S)igi62iKZ9bBv=h|8&>slfFw}h+y`j29cUzM`-@=v2EDS zVdGdP9<=G2vCO3nhm5+IEuED}vlyX}JWOYII18so_SLIb6LNAM%UHaN)j-_ewMl^z z@CT-FnPpRErlxMs0z3P-X*3Fa z791XA(1H=44`?+1x51_X$o4-58!>hD==3-LPmj2KY!qE^mr&2_$kHfFKgxz@T?q+a zfj>tV{)A2-#Zw!rB26$Wewh~ndw5e*6TS~J{3hKv+#bUYcYabC*NaRf`xAy`jM8=+ ztIB}F0*!cq_$JB6{&@hW$L_knuWydi5%QpxCI0Qq8QF!c@kz|Gc}1~=37;3u+qYnB zgL#;{o*D2RNJ;%e<)z1GXm+c!ps$Hb6{He%DUiwzH-&T%UM-veeNndkrT z_7+f4uJ7LP04fHdqJpG~iYOrp(x4kr5CM^t7)qo;x@Qm+L;3k`0e9W~2~iA znfE74Hoevd>lMtfGue0aeiODvLZa%kTlkFvm70R){QSGirR%x-gS1?XtT=i{!m{{D z+$uf>#j-lRR&FC?KOCK*y!mKlk?Y$8W~N@~&6`!l^zQTTK4Q+HD+nf{ZY^6c?DUV0 zXrl-jn<&549`<5WBohiE^7b4^s;Zds+F0*BkY|EKV(LP;^Bd_`)lgB$U;JLRo!+)O zygfxFBzOMD@(B{8-bG53bj_szK^u+fqvw8DoOXFE_2uU~h8LNO%vpufnd{KQpxK`+ z_h@Oexg0ZjDg8br%VOI(RsCpPi6)D5V$yKuAKxoQU{6iaa#15~nolwv1kCqSjtkiB zayedn@`mk@p6Hi-F`SxDxw=r!WSE)C#eAb|4gy@0`pup+T1Q{QJptFc-RP{dm4{H! zz6lzkI15GX9qP6Vn4;1SbpPl7Hw4p@e<7G!*14M+8~xp_9RPk*W8`DEw_J~>fyRj_ zLWqM7x`D4-<#S~qRjCRCTE@HtU@vgs0;?w>At9v!QkyG6Lg)(Std8lWA0p(jkRU^s z8~W$X@Q%jHo3djDP74D?((A(rRGpdG*GIBke3mWMBtf*4c?giP|91e>(|-U= zP11rFy&jbW18wikOu0BD)$-F?6%XwkfdjaLdGaM6pl{paL@G(r(OIP!SjQ1zM3S@H z7CH_Ks*4Mqo=TI2GinJ1k%>u3S^+xr;!s_|XF=d$(A9yD=8W&9xBW>_MKoVKWIaD; zCoLuh)I8tv8xC*a9a2|d+!pq$ybQ2n&@t^=UC5f)6N1Fwe*g`MI@)nrv_>Us{dcRs zmtg?V62z?0?A&Q+WFWn^W(n1Th!9(R+YRCgWab`tOYuW3XUT?c?GFZDQtWFk>(#+S zZc^-0`gNJ0>mL@5Z?T@1;982*I2eC&Q!#z&tsd9G{vlU+A(#GVpRwt$rEYcunKur5 zt=8PL6I}6+-g3GLvS{~Le;<`fC<%**TpFNjKzW+C#Z`y6Z6^lQv-wWc=ZNx>RmO)u zx+2{|eyK=;sUbDiIMGa9@b&dtm zM&;>S?0%;bkEU62s2lRiEPZ1}o?Cwq_7-_=ue$#;i{U+PRoGc+qdgCM(xkp+8&a2i zVQU`|R3vkZIHOEi`u&9Aha%7E@+(%EqWI^nb`Q$1I^&+

EV)rp+jP#9VoAf*NxrIw*T|*n~3V$Kb%>G86$tRN7dq~$89zmFF00;mOVS7iH zn$JAPjS1E=KvW4@GfE_s6Ziwt397_cn9CQuuf~f>Ggh1XBV125gbD%!vW8hMYay7Z zBXDoK;|%y`dJWp$pQE2-btiw(c!sl4P`J?E-u_FwW7@J9n4&dW%`HRryr83r{NLod zQzuVSN3xSs&&Zy#=?^Vizk(1e5TVwtU_c`7*}+idtN-EWLQv!J?lq*3!HNc6$1iouVD-M`TP3@23{-5glj{^+)vau<#A6642S8f1yw$jmgS^%VSZQ7Z(EHdh7Oo6&jpgSF}<n0MaO)kDBYN z))i}2xw4_d@11*S~oqJ|8c*BR40TL-0@-)N6> z8(nRnA@a7hx*%&bdpju>V?=tnGtBNCfzS!(5rF}2v;4xlDm|HZrgRod{og;})i;~? zzC4kT2&haLu_3Z1^J`b`%ndq^GbE(Bt?~zMKBhgqRM0j$2CH*auSM3pl>|y!bLp!( ztsyj${nDwr4fxMPJUcxvd6(L@udi-?W~~<%6NO!n(qDGIe%L{w$vQZLV9_dd z^Fz0J%hDppeKI9^Xy%fe3VTB(jcj9#?XDSb4h0-{FilVhoVEAgUQ7&IWs*6Nx*}yH zSS=d0qE!t+rtj|!ivMHk<2jc@4XbFrJ}MmG~)q#sf2D0k|rR|xs)R?C6L@( z0TE04VyN5U?~T=EV%*B{zxktnk4&iskN*n?c>IHOrGwwOU++CU9st7u_+EWeq(6Sc z0Bh~+Q8C5EN(=L`sy!D}r0c|S)BzT=dVSKq-8;Z-@E@4;Wh8ZaLQJx>=P#ROUW6v2 zH`@D7>i{E#yg_{gQN30Y5E26T1`=eJowGKhTrs#K&dG`Z0l8cLXCUBLcDAFeAP_nK z<@@Xq#roLE%z2}&rO}m5Ibiq?P6R;1>DTS8UpH+AxFQ4=kN!cSLi~6198SvO|7IR!xl_2wQr=CA0R7qvt90m}srzNauAt z=}|m`dRnYT1XX6*1l?*o)pqJY`b_SxS!?F&d46ZJFs{+T`pX0tcYB%p*kl1gL&t8<`mqiy9 zhys$HPPHi0H)x|TY)>oMMpVKgad3xdw?9-n<)ClRp+>zpmYpw`cgUb6w(hOeL8O3X zWbC&chfexTd_>o^w9&86H}3hV-Zb12Ji}8wIyQfn>(K&Eu7JJu)#(Lj9C;nHf)dOP zGiNka-{RJG_C6sB>r{Q5Icn_5Zl^MP&XF#SY4LkM6;0zO_rFWF%Qn;N4opI}7|XDG zWY4u)qj$SvaJDID)oJUsm{Sz5B3ok_Rluhg^RNjeT2M@XYtT5GMge^4q|xZC*! zK`J-4s9?CT7xih$CcU6=r>u)&J~7jrzn2Tp5eO+;0&ruFLaG^limM!>||d{FC+JRrWw&& zihl=wC${F$yN@w9M*ul9-JW>QG|d1YZ-A;v1@ds!8#hxuMI!3<9|T_OEvuc|7Oxy$ zuh^S5*yoBG^2-LvF38H77pYZOPY%6+C7C(-CD8nD++)d^$^A!;3?+QO#1PCQrh-!u zccVFimjXr|y?@G7FmB#q19%Q3m>gztw_klA?qtxTnu&*QwGt(s!arwklxe!SMc`s? zv6%EJ`xnw2DdL%9mtX$5FsT<3OC+hIYUFhG&!v8Ek8KUT{M3L2+afkT#(iGLS+qn? zewFQ6p43w=3r=CT6*rg66UnvoqC(3v;pYk)il0dRC$now#EKYrqQb};m;)k-_iU%Kx4sj{IfTKh4tY@E3 z!9739K7z9Jt~e#Q7>WGUZHjW01JSzPeDBVXtwR{mIBzDa9P%&32x?u>+UqT(pDN>3~57a-LI z6Gqy(QYACLYa}zufZf>lH}aH`4MCpD<{&N5l5`dp>5PH!^t$taJY^4qOgx-hFs!=N zYT}kW2%bR2s#Q3Ebjd9qh;gp~)dW}%KJw`bm$|yOIh?~kByV(tB7w6S;Ml)n-;f+5 zLYRCC3fGD6tDKbX@>FQ)Oun1oe3k|GWDx(|-Zv^N*k#GsBe_N0p(iuk{_9lISgLjN zs!5gv{)e*{YawU7ovGx=V`YH#{}_ZM1JNP?;s^lC)9FiL%>Bpn=Vt*-u z!(X#hY)aaN3c{eB1ldj`I%Rsgz9ne#Lg`ZE{0OR0kF{sek&tfC?!4HW58k5_3)c%2 z)1En%0`RC2Z`Yw*wz|8r6s@@6JIUtnB2@7J*T%cUhGe5VLc_OLTD?wFX;!S7M%`+( z=c$CJK6C09SeDc{bTfIl{^9cR#F)r)`F>)8X9C}FWDZb>&m#vh*QE5=l)At1#!aJB z%Ke)R*rI!+iWo|WK~Dd<)mbE(VhM=4iX=yo`>gHZ2^!A0z8mKH=B;@eCE9JSnBG6coYwL zRzOEuN$alHj*y?*yoJKwQ3oU|kjBu8DW48|7L;(3mA*rod>u>uDl$V2xr^C2t$n#9 z^OA)x#P4TWI<}vPe7)iBl4^m;PkdxW)v?r>BqBBZRKMj!<&X^azM1xf$*gcOo=KYS z<7jrdhqh;L{M1bRM9JN`%4b>JzHxnE~EiLE?e(}sw7x@`G(f(q);Ir!e#mg(Rb#Fgn zNki%@hZvp;Dc)Imld0l4MkFi&GF$~4J6!JY3O zeD0IqM5;wiWA>=4+6(rvB`?&E)dM%w4wp!^&#lY>*_peHbkHp<@Vp;fGmI=4Wu9#Z zmr50y{8|VPdhA+io)Oz>J>Zq&(;9JuKlnPnRCSK(O2oS)iOVHqM)ZgFXTH9s? z!0_xfUfz;6l!~mrk?_2JhLEdkv+zw&S=F-=`QFFt0)rN&N`(z&12W}V(e%2Rjx&IN z7xldG9hVfaJ2!2eRDg!*-Y?A&P!5ThIWgY!Be~(8)~%nfCnHIP-vYu>-p&sJ#)tzl z3Ftvs6$zXyof&@5PRqe-`5~_O59AOF2clHtjb~D5 z)$ozq!ks;Iq&Cpk5U=llZH-4GdS7Unr%DPtR>zrpzg%XCmsA9Q72@LCcO77zllOC0Gl!cd_+0>y$9HY}4b zm_U%$XBlr$7R1vb6ewanQ?D#+d4bdM^I-ZjUdrH=avK^xDXCyFE1k4dh31|lD~IR9 zKSdcBZGW9ZRRyl#jIaMlTt`N|#)nZk?v*OxiWY|^UH#~=F&j0BPLM}yUFLQn1&kEc z-8uo4pU|<52x}!mEtJl(Asmmrt>u5IbG?6mTwu9|q8PLwLH*(s;7mbTX#1F}0$|%f z){Tm{T|mkNbdErv34t1elnnr=32la~C=qQ)P}+z|`L@yI0>eAotx&%D;oypc#jhYLW#0DQD>(V5eyFZFnGMk50+Xc;Bm3zg|G$H@IS-$ylv!tnutHpUPM(OM&l}mhggGOJ z3KMd7nr80#v})XJ1)WMtF;5E+F6h>l)j_TMJ={sWFSGNE8Aq|`I&+2FAlzLpgwqQ^46VD}nmPzz8-RWRgZ7=FX1oBlt- zriFjQrdJb7X7RuhvIQ|x0Jn8c70jIw(k3_zCWtnhFdM_|qUcNr%S%JtiI#EX&e_}K zIkV{ms`@u(y9NZx-zid7aT6?2AGbXk=3cSneS~lDl5q2P4A0aG|8XQ=YAaGWv-INjHCnX;JIw`qW>DK0#{SkRH7~_+{mB zeo@6`>Gdc@R3`zQTlnF?<+?q~&x#{%cjy+5?7cz_$IlfJ=`#CB4`!1Ug_&lEbANm) zPb%$YXslb9rLu_mu&OaxYLy>dctNqnpEc=jkp1HD>t^xjGYuQh4~d?wN{R5|F{O}s z(W`^tD99;wcrvY(v!6gcbJ{u1M*r-PY?j)BfzUow__*|8bez_?Qj&|OMzREdQcxLT zhB7HFaqcm)l}blOIL;@P&K6@@b{;4yHj3{~gZ)a?H<=Wi`GuZV`522%y)}nDE6bcF zE;$&5VVFm51HcnS5;>Qu zv&A?Jve@ogy;U!YTACduJ{YQQOI2t2qfUsd(j@jaJ@?3o%w0SoA1N(C<^0vJX+I6uUs^Xv3>Prf z$*zEgi}C)c!t^!Wl^peRE{pV@PivQT<}ah&j7pP-Ka3|F2;UHyirm*rD6jlt^Hj9e zAu3=qFYRSsno#2EaIChjrB?}qP-Ge7>mOokQCqZp%J$o80$ZVJSp_ESQ2M-WRlZIv zo((x%QBNpMQ`=Q6gIz#c2|;P4HH`-aGZt*VU6GB%I9M7Ej=%pj(IvaX*#E zP421(2moNkmxdVWK!r{3M0oGN@em4$@2ZuUC(aye05VvuJar&+Z-5|yLcyTHNN5xX z0%T>odVsJ7xwZ7D(=A7a5hh%e30+4MfJe9FN^LW|j2mTC^fFjz=aN~yXJo1KhJpaa zhe*~^T<2UHZ)$BV;$`uV-$-MLFQ<=y3W>e48v-h?%5D^3Xc?l-RlIr9q;5#+^A4}>vYNJS*SdKwYciv*+=qt0L!tzX06@N&EQ?WbKtkKbgMhtz>!a0aw>mGRg{u!EK@Em{-gfNsb|NC9c7|&k^z3DwP=A z;434#==jfimq8?9(lg$;Am83Ba51}?f}J~Qf@nZG<`@f6Ii0{&H0;rR8mq9DJDN7e zZ`Pl2V3;SEs5LM8j|7ovqYg*XJF+UL2amK7G}5?`6>g9QCA;w}dv^_+5lz^UI7Z*A z%tF7UZ>iCBvCDAi?Zui^(#_|sI7O&Q4@T7}IB_eq>|U@?BM7oP(X`r6)NZ!d@@0uh z@r@ytvvvED*@mr=??xp@p?9@SR-)6ufMO%5ce>Aq)GQhLXps-4X30p#6gez?O2ujh zT5Fz}PB}e7rZvm@=T}SZu7<%}gXiFleM?t`k8Yew#VuX=|(!3|5awGG4 zs4v^*+cO@M-(h?};&_Lq;=v64+S$h#WfT-%7zQ11dGZJY1YTjnSMy$Y96H}zE_y@EDhz0PK%Q4Fxa^TWpOHGF z19+Pihhbp&-*a#NY(w|;bM}}JtaccHg5>B(8V;aE1LdP>h&E06dbdj2X8?tR&W;V= zkE4_=(aD89Fya&y=QRq6zXR|Rk9hO;braKKUd4V1*?dFYRtF1sU*%TUcyndmezf)^ z9h;+Y@v2imXsCXH7x07k4mK+lo6T+lxCZHYWoE5H3hIvkA%@EjSk?HkwcQ>zQ!8J9 z<(8gM!^UV9pY%xmynG@gUH#6X@AJy~N82}J(1M@6AB$1wN<5WsIrNpw>$>ywn|#cK z2uDv-{h%*7>e;5(oyVC9%a2vv2LgBtJ$8rum?!TqRj3JmlpJCMBu+E;<$%dSIi0BS z(V1+z!#4vF2Hl|pdd6?IulxF?HaJGK6w(!`a`hyy_+XLI5ODF+26RFP{@D6>Tb!c2 z@|V4XS*Or|C(^+f-x(QuvW-r^e8ezYE;eh2Qah{{p+>t@5RrQW77QlZ^=*a<(?`2MB8k4GI?b<$Bryvp4pvAU|+w`VBw}xAtR~e zB#WZAG%&B)c?~!LYCaGVo(6y&m>zn<#ITpt@{5->DdoT<^wRnTY>-2{VC<3<9;bO1 zl(dD*_jzFCSYSs=XK(BxkE?2)AG*=5N1Q`4J+HrjL5`-2*rLLbk<5j{;G`|Jo_4*N zKGA+LX??(qfmTz`jN6G4H-y*HIx+rK`kEpyA67CKHxV~?(ZB78bWnk{cMJcS0(AoH zJ#u!Zv^}f=A+SO!)O7nR;RV=>k+x=ityURspGMvJe?km>CyfE}2$<>@hX)gj3Rl{{qFdPz!?Um#7R1qE4i3uVyWoRy(_EkJ z7ATH;bCW`$)=WEhC9Yd~CtTn^JdM2YBiN#|z85xJ)M)EIfv|S~z_kMc122Q(E3}&s zUyPEJ9qw|?`v?uE1(Bdq2Mwf@l`sH(p7mw=UlVtbUx&y4PUvpl1%mw)q*Z`fVRtUA5kax;-$#ak+Pp1Mu@T z<>(K<3c_L?(ERxMtwlMp2XoZud6KP`)3V`@<^wIXo`>TZ24Ao2ukXv*MYSu; zTFh-J{tz@0Ds^wJH&wS)|F`aNX{@qn&g@S3OyZ+9@uUgu*}c+5>Xm)jCW5P|l#z}c zseSs!lXL`?Of=m#eRdpQnz}I_o`Q6bJeZr4H=YEBbY72O{eBeH86oV^ak%LnGW4?F z!09Zk-~T)c&ShTdl@HHGKNaTs6^i-ni*1OQkvB*;K&^z~zMe&+n8d@2=G7 znSQ_7{g3O;VKylL#BoV?Dvll@{B75D;y~U7fBT5-L8b{#xQ{C=-AsR7ChWJ4B_uKR z@bFp=MQ!bO@AcbOh8QO8cJ7%P6TEhM7cxta-^i}iuMC!}_h}NL>SjQtQF@dhSVjw* z(VH5My%F8L%SR!fGUX<2$P()Rv@nP92*79I2)7Q0cW{Y2autg(YCLoHYY}TPBAwK3K63sI8?ume)ivrZmJxgHTC`=ibBDj@#I#=5~634Y8$lkLgTl->eyOh?nBG88-u=_z(U16@p?xI=8r`vmzudmg4V?_grQU7R+ zg=Kp&_(az>WDtJzp!ujTYeI~&BoAi~xT6xa%`>)GD$X|hH1hP{cTQ#%(OLXQ@hg0x z?^6bxvJRsB%3|kwAV+w5! zAu75`1Bfpk2NuH*fgO={=XE?SYHO4FZPaES0QcSMkn4)!} z{Ef1{9G?{5v;TeR1SfY2P^w~lq|ZI!dR-uN%|~eG2B-+ZpNZyhrdJM4-CmWoi|P?B zqeWz3?}z#!GIG?$>!OQ~ussHLv*Y&_QQ5T>dBNBOj&JJ>Csk^TTk^IX;9BpULF)x-{5#W4{W|K z-gNvC=!c3v3^lMMBMz;@BdoV~wGdcs*LY#Q?i{RMLSnBYEDs_f126+LP{aWXF(3&D zgHSR>R93+}nfR*@s;|fFSn?u-5je?z{-OUSg3^fbzV)7~+#L3@0y_h_6ypP1GmJ4y`4_x#u^Lyjo=I>v!0O4&WSzk{hO6l5X~46 zStRv2J`yFu=RU2avNuvCHQOqeK?^H5xL^_chTWR3j=fSZr_P@|Qhw6*UFabe(9rBG*K}tR4~S!S zxMw)c9MG}%>us8F&1saO?wXbpN?~FD6df^;@6hfW-pPvOO;Ub5qmxLh{LGN|h8sr) z?)kgJa&w=Xrhq>Va?t3b345kx`2K+}=PwONxR9YjOm77bORPDaG}6*XU4rtn$y_Bm zM`P|lfDTzn_2atBOKW(A>f>yx8d>pyFA7~avE5djL=5hdJG!6_UoORn;}D;b0eIY-iK zoCCYfpTN3#FP>7i5Yvdh1u&+rt8ds)|Hv#UgD>g{^S)D-C59kY*JS6&G9e&zVV9gX zxWklQt0ZTzv&hs2RfQ!}glD2OMucWLm1aa&8VptB?NB+HKl%4;_gGLGdxE61{~S&- zjX$k(8f`1*6R$1b86heXzj!>aOS@rHzlsShm>djYqt~W%csYS(OMfHU)N$^s1b>LR(=#!GHdW zK71q(cU9$@ZJO;=dDO95Q7h1+Ae+W|=WswXAjCpjb+<$nL-)hs{ms@hnfPycPd2$p z=xleF+=i_s@g+x3tn6u!KW%xqmNTLWImd15y<1fDn};=Bf{&He9RETtwZAM;(SYZJ!3 z&(%+EeIzO`MGc?1?|uVyFtLI(VkKHxWY51g78l`tT(8SVWV?Q^GFs85%Cb;%x50cp z8D)y&Z64N2k1%jhbS4w&5@EgRQv`;F$6UWm*HRGv!3aD8LNuM@kNF@6q(2D|YUixY z)0Q)oo-HDEF}o?##kN21`UK3{sh>r~?5v$hFVEuGyd0x=1ch)C;u|P<^Gr6|@}Rx? zv0uGqTy^=;{amNikC|2iAh}EcawByhgb6;=Uf_AEpjvDEzf)9c-Y8^OtN5VaTtWk3 zhYwq6Nz?-%Db^+&vV65+rWTxg@!B?vR?uNsMv|f>XFDw&uI+WE&F;74xCs z83}ZR3*%#H5r+1ox~*!Dw%hLV*v$x_eyVnzzbfd&j?$Qtbox=?N#?ZcrqLk6m^)QO z9;c#6hx$o5Znf=108$o|CpJTgUQ^IhxlshlZ;gIyMFe2>wxw53_ovhR&SJ_d#-gA3 z%_PF_P3-k3eJnP<1iG@PX*$SrIreW)dgM-Z<%K&M{aI4{E-Usx*?<_uV?9R&hvM~Q z2ZR%UZkD%Jm+{Jpwe^;t6e`pd#pv>tLgz` z?aM&G=zb6n`!m^tiuyP1aAij*$^+)b&d;QTt?vOmC0nV3BXtfL!U5j>KP+&<5A*!W z^|CPIMTAO;P{39~*pFFbXH>WkF4%aP2}HaEIY=!(BfE)lB-H>n9gW6r!IfuNB=6f~ zmpY?JfI=eY#ytNANW_9W0(1`j))Q_@o7cTQah|E=`>OMU8{CHV|ObY-zyi>2z48*wQLnmuWcMPp!8ZRcv0!LBq ze)jDuo2+8D$DTe9un}aLwyk85 z_}`Ghjbs4zpoBU8an$eTB1lz@3cKH;BfDFCW1T3&>F|{Ni_}3teBtv0UmQh__$?6g z;GO?CdKOW}B$rlaOYU1~a;a1{)*dOUzs+xU9<$emVXtM*8?G7Xi%u)t9$(HwevfBo z{9;$>mB;JIS5og2Et~jHfR$;CN!1NjKpF>qlPtNx6f!D>zzzjN^{*iR0N4zzd;fZJ zS+@zuxEEXi`!r`Aw!ihI;VU$k$~#t;hSgrk#LxZF83iLwB9u41;7xF#|NG%f2j*Z` zuIvI#1N9Tcnzb(c{i8Ey`o3sT%A7)^*DJG9Ql8=e^&?*=khds60YdnXKiTm}=KqgC z`GiBQ1Y|IG=~{g+QWT>bybM=b81 zE(l5TK(5zy$8=c0tm}d?cnAMmnH_mU=KnAgCki*Y!D!@{n=ketIHYstoxA%uu-idha0Ay%kP#z5fm?zn19tCBG-ihxl)0ELm`Q9d3?Z@vz|Xi0*1f1D zpaYJB2g5nV_2ZcXs}FHv0}Eg%3o2KLj~HToySp(zmiBij>HGj*I3&MKY7yoBNWrp{ z+TCh~182~2qJ?Y9KdD7Z&3PQ5JJvH`KLU;mRRwpm4O%#eObV!|C}c|am)O&PD}?va zMusL}hI@>J>JqUf>JkiXLDsbDZmU52&GD>ydIu83R~%-mzp7+q+dm6RcQtDJ2)coM z4?S2qRD*{gTqPk2Bz)Y9e@mYHzN-aiNUUW%fxjsr0KSPJ0Eu!2MXH}(+{0BcAN?(~ zRbH;j@`t4JoF9V7>8{y>jm?=r4Z(-YrOroltV&2lM0ePYfujsc%*a zLG*+tB{Xbu0w%u*4d<{orMe^FJU)IR(y8~RtL{<9daj5aTn$R zfI^G{;tLD9oXSUFMp}i#$c#UuZXq^4|h`aak3!6di!IHc1C2+^h89ZLZJ<=^JEm(_^ zpcu1T$^fjuC_8rh6m+lEI8Nmp`GC5g-Q)Qp%n4{d&3x*CLGvZD>|pmCBcM74&}IPO z*Z%x_>&ozd)u1d;ihtxq$b$3u?V?>Kj))%t9X=o!#{3$vX|_6|4_?}^rNK<@`)q4C zw{%k-tW`5mp_&^kk!>Pm`H}$gK@E$!;JO&TsNEQSu*M|c2Tna>rr2E?aHwX^N((KU zybQ!RIQR?uTf4x%J5x5_#olfkADj#NsEBZ%R*(@QQV2T=(KDCd0b0J?p<*d!zci%N z1c)0UAyid&_F{1w7yph71!~ATy0(7ZpoN~g{kGm&K&*k~wkCiPxAs>(wBOc=a%6a|iKOJEJGJn#7&#Q8QBhCC5pt%hUZPXrFw_3jLqsaz+kNrAVp;SpE? zsDEZ|SO5lN3+|%vv2tY*oa5pCrgLI52{or!(<9iec-|B;1|gPL1J{~ubZ%4CWLP(& zamY_iD2T~u@53{ed$oNoU%Pe`{67#=2YSb)2l5yHy1`crRFLH-aZ-VE@`o+5cwi6U zb-+lyH&2)Rw~};ZH9a~jj8p$Cpu7oCP`ubqgz6$qSAo5yYUEjNWOO$#_~Q0A2F&+C z$B)>weA%tMvb_SR$M0)7%_aqIY_0-?cm+ex^BR>?zIk}Cc$n6ZA7tN`r|YxF2Xx;+ zVo@I+6e6C8@9V%h*w`H=YVLkB!>!Hr$zq(q5J+IP=`IzA))Be?bK>Yyh8yF1y5;E9H?*$yALHQg}dIL@A9_)Vb9v|p+AMQ;G6QCS6 z7it-O1Y7_1UFZ%ih~`)xsghB`h(XX5l98@)p1wWrt+idcL3i`#?Ap7Kd?Kg8}M9R~Rc#T$W<&Pe@T{-^>{( z?tgvMUs~u3Vki|{xpy?XV&^8Ju#8yKjHS_!&n!7tyOH{X^(3b&2-&*`p{J)cxvdh$T^@`}!KN=ON%33{U}3kTfmaXz8*C z?Qu%~B`_ubZd?rU>0en$0m1JrdsOTX5fQb+c0tAwV}0**8>tmN)9W={$k9pEeP&!l_6k*03EN zWjApV&cT6&l}=gd)CPmtb=W(1d3cndg9z|~K=px>*9+I|eZk}W+%poL6xy##5WTW+dSdtW2)$$&jT$g+E#RMU2U$7*j@{qSt}J#ZwX z0^vfyLfR0bAau>$j?r%IOqJ!Znvvru`Odk%k>BZg&gh6U>_oLd;zkFzw{KA&3jYx_ zGnOD07Ou49$So1H*wx_p&Wt@+8G$VSOGvENbI;Cm^-BxG3^fS~KpT))T3-GF$3FoI zj>&ptqXT_;ZKmrKEV*j9=A}W4R>XksE6kT`iV!olrF8ZElW?aWf?()vQ1~jD0|Cj? zw6rXn$HPET8?@;_y%$+qqafD+4jT1wqMl1%PqF`m9S0(%b$~3EL8HC>nWPDWXr>_U zbD|I#OR%$k3_A?(Gz)%QsGl=k=_|X0_rdS3&WC|q$wdrU5??n*us+~Mc?D)mkdHis zjDY|JrVH-RpFSl)lr=$#0a5=4w~TXqHVYp$tKBq_#Sg|`_Uki=GxG_7-K(#s+pm43 zcdX4`R( zI2&0a^VE;-N#wQb1!9tBB#ehfW!&P1>+T?5;v;;(W62j8axFbA?dy+N=nP_aI-+kw zV~OM9?=WvCRt6eK0*8C6V>g|3LU1q-NBp&=WP{Xx3qn4ZmwyL)mN$nec5viigF752 zgZc^gKhRYVv9rvh3wn3$eFE*xA8N`ih~h4ihrNCGF16bq^!l`BI#W}iB1z=uD-2R# ze|E$m0BBDxuo}|aHBKA>Dy8Jp!s*%U$~rjuZ(JurA%Nx0s|aWja=04#ms-z$&^zpY zeAOGpzaAGc(ijCStF3~Yw1~no#8lp9JQ(kX zn;XlduY${q*7gb>$vBak?iIz*Goxr$qE@2+oc%JpUwF&J>UhiYRp2S8pMNcqS4F(A zeCm;%4ONr|lwVrb{tl4IBJqJ%$dL-ULm`ruA_esup?-cYQ0sAAM2Z%ur)qz_YNKx@ zvO}vJVk+C}ZQ`-g#bbz$GuVC3y%MKVCu8)Z;XA5WI z-=|oaO{~h!j@(ji1V=;6*)zTtbIpB!_6l>ORH90pazY+vek*gpp7#GH4z{%@)K7?$ zhz4%R&;;U)3n>ne5+ACm(IK6ttoD}>0DV9KmJo6NT!!G_nSOfc_Iv^2b`T46FReQW zza3y~a5NP3to~>RP>crnX@YrT+2cwYFv;|StwSZ7^z;iP%gb;8X#iyr3Cv)g0><_7 zx$gs!N)mzxq8R{5n=`X)QtzIS;i-{D@0MyWp$eL9hp*?6NDZ}u{yOjE;-U}CwZsA+ zdHt8Kfcf`V@XZ2Y;v=T-$frX_irV#LX6LI`a0kd)0!AP?%J6@0+b(H9^y}S|EP(Auei%L?CDJcfcbe54wZVSldr$BlY{ag7aS{}hTkJ4I!59g z9UUD(357irtv^F`qhtbe@wxT%)o@#U%*N}Gn+M2o0+XuE&}6P2yQQm&Sn0v{i|)h> zj;BT3NC;37051n$qTbIsr{GQF>|H)Z^ia3#di^xOJD)CDp8QhAtFU+W%3} zqkmWQVOER7bnF#dR}gV{;s#i%ZrPOw)W|jhC%>92nU5@@h^5WfH*37MFi2gZp>KiR zZd6~&#_pdgvstWzCAXZ7H9P*ARk_-(@{DR`@W%bw_2dFo3xnBt<%>oE74eDgzO=p- z1BrNKXM^o80g8hIIRVQJobaWHJZ=PA1$lwd*fyWz)@@W(YpXQIe8jhVWxG##!~a0o z0n5NpYd5lcXc}kB@mYyD`)zJb+AAB(&=W&b-oix^&DGN9fM-| z1c!b+oSdJuC78TUklwIzjil9b(Q~4G{YN}$g!4~N9pc8~!OAlD>Uq3>+0;(16=FO%#68o zg#qHQ%Dhs|HN2erCf#e^;&lw&b3-@^Cw8FTk?>hDKvnGK-I2R-`d z+S>8(0_VuwuZcse$Wk}(Dyv#)r-07z@NgC!mHN=r>4&2vEG(>jXP-6I$o(aC%B+VH zm_3Kkv}NNq`eRtM`KagSg`TBQGG*^Z_c)4WfdU8f9is^-vuX96cpWw}PCUKG#ib8Y zU-m2CnIx+Wp<4Ieo?q>E&$K?IBQaUnNXyPvl#uW|cYuh^_w=smAdC> zw24Vjeqo^jT)ZN#JT;)fx;O|i%bwt0JRo}BID0V3#o%Yyo*?d*P&9&3fvnH}Y6m|* zT1`#u`jRFC^%;=?4~lYGderGkO(2ge4tk(*Yg0dHO?Q;dCT)o8Kap7jd{G%ZHMX%f zHbtO5W(lp4`GKPBo@7k}L&L;xIme)q`vKUD=67bgy2+4oXVld_x{KAOhQXb`M}Dq)wdFdQv(5{#pU79`x)}0a=KyoA*igYCfl(e3vg8 zPq`<8*woU19&BY}V+h#Ch3}7U&elGck%@(v%@@?uQ@UPMt~cPRxKLQuKM?AqZlI?( zwR}~_%Bo;xW#uXFpq*CRz&%Q7{;SIDCb=fjkkmpObf#?>%8ff#r;Yc=jEOya9MO%b zRE2MbYdSEvlK%M^o<@D+#pdSbm2Jl)BlswaH_o-=HivIjj$}W(kpMlz;a{6!(kCLg zams+hhSP{{H@~(5@%yxRT1K6*&!@bb4Pw51eSPpzl%dnAt*kybxe4C@XtH!@UpjYk z8fpNiD26e7!G3w6NLo*;(_DdPVe7Toy%@0}Hgvg?-x{0|>kg`l5%qUBOYL~GYb+dd zSHsv9Kz>shS`9DLi5-Rz@it>)WAA+3Xs7Os_%f5ZxPljP^9%S=fivh}N>f~~9j}ia zt8QGmN%Zm@)*8K1>wx=ZQl_-A`LJl#QcF1a#cVDp4{tjP%|q0yx4s||-Zwd&X`;2w z@9{p7WMjX{sRc-GKcI3wfBZ;*zyG-HX37=v@nVLlWqY+tKJ_O&d-m_q?-vAHsWHs= zH{;0r*9)br_gTxhmnT-0TOKY@UTt4MeUbtZJ0@`$+Iic2ZqGaAstnSE}h=+y=FYnD8H>P}r0jZ0$elMNA zt^el!eK?;K!Bkfp4YvlqTUPaoL5@ll)Ai>H3P~W1&pRR63FMo6Fc<}3EI-@SxZSi7 z{3?zsBY7uI+zO|(h=u(w<%6&NY=UED51Ow{mow2#_0!|O(PD4rX|<~gP7c8TjVmnb zy2VJHJ^zy{y2so;@Z*N;hyE{yyi8INDX==3&FMj?HR}SKs-DY9_)Ib*kAGfMkDF|^ zeKGKf>tWmdQhbogRdG5xI`1_I4IQr_1ced4afXCjPE9RyX2t*<8lU6wPQp3xJwr9{ zY6e}n<716kYB1cr)=!qIT=|C6W%%^slAkY!7*PXNfR^03-;0*FJ@VIfY1OQmR?Nl#aJ}FOosLlaPT2O&DxY86 zC(NEFLy3sHwNCDq^0%eq?52h^YZ1CP33Onbi-j^D@FnQ-h!`4lh6y;K4WEN zrRLCj3pHyZ*qBO&af%a@({ncZXGXx26b%j2Ur4E_gu~7c3=K^M2(()9>j!YLceXP= zD@WhIq%p&x?4$&Dx5=v?c0Cd;*V-iz{LV1;&`C#EH+&rfO{}3lFSznBO?J$ZE5n+e zsYKdrZAE!^w*sAzqY_*K)b0^58)J-aMXnYCb?Yx*_xZbLQioe$3Y!e0ObI_oI3`lq z#lt|gbq`YsrZA=}*GdoV^xYrM)UpQ611wevI+pOcdbl__r;tnyE*#R=R45PO^NY!B zFtfv|R=!<9 zl)f1F1@0f~OxP8sQb4KWH+SI=7c61erG{XLWRDM2d@9uE-NQZj+EproXOu@6WOxig zoU);%rF(a=azuL{x73dD$cNa;K0NF3u$9~zBIBzUFb?==KLl9+G6P**$)(YQZ9*7IG|x89_3%%%8x_07%q(MKlJ#}; zb*F_%TK`a^mEDzcHuhnA8J9_pZ2a2I;rR~aG>)!qT=xs!7o;M&p0CCvA}=2ZL`%f| zcNRSBJ?v?px=B0D$Tl=Jb<&uYYqs#in{+|o=hCWqPhX#)ltl*xn27~ZWzmiYe(~zp zgH4B9u3cFR77Mz-$&MB9!YMaufDh+*$&Yjjbd?W!GXasgefMdc-5Yhq{1mx5Kj!@~WG6L`Anb(=y8^NP5&u~8NT(!QJO z26iYT`vvx^7JAQNz__TyyoUYs@sRt9kY&Jcq<}?5T577emzNjhR6URq7dUL17!Q?p zMGlR^xFh%q(h*VE_-%K$qVE4k;&CVX^|oV0AcB16em4XvKRu<2(%Se_^RoexGgaC~xc zbEm^~!JeIAeaQ3`LaN0VJ7;K{s=W7|jBo}#69|7Yp3157)Fcv-fx;LR@l1|S99bA9weB-J;eV&9UuPn~V z9=~qS90n)(n+vbkZ{Vn5hxK_QwqO1SV2JX>w{$9-Z3tv8n)#~+zrL}yhIxh$1U65V7xJS>xWq}Vhqi~*qJ+fQ zZ1(GRF3!QrNfue}QgA}|&bmo=vyR5J*uP0KGyTh~0wY}|y@b4f`rNYI-?MmEBg z7ijE12xz{{Wm6c6>ym)Qb`ky6Y4leuw0rQLB(1NZ{7&JHrlFyUM|TduP$Hb+{RW59 z&~r8R4ZNts9YkenX72xC?>)ns%-XJDW^ChFM;WmJ>VOOuA}S)%R8T+!6j6Fm5djep zq*oosih=|M1p!5h)XLJvhsKnNk<+KS$DKi~WPe1Ck$@o?NlCCPPV zUwfbXJlDC_+C8^UQ>rAatz zK#wEW{h^Y`NT;pa6CC-Y85pKjx&&MAL*bdnc{Be(gvdJEq3xPpSyOYup9`@y^2|-u z%a<=-S&eU7Q&W+@=??)W_Oq-(fZyTj~r!m+6S(TE}A+rcQ|TYK2xA(RtLKa!){s9tx#YV`H0k zSLFePw=+HDHDga?t8CzWT*4l)zB)SdKGWrMXvHFXxx#%%xH0L%izF@Axyjkjys!(K z)ANZm1LXvHd3j;k*rb5NLMUO@ZQLj`$!lp>AFP(vOlGl5BG=%7zVk78WtH z_j#B`gk=>W)d~*}Z%DV$WGH_&pMB}Yv+mO49nL5?zK+ml=7O$XNZH%-WxQ(9!*&`Q zw8*#tPdoLL2jv1L#z`W|m2WiaIHB-N|K~%hk+E?WCR54Vn_cZ#m!zt3>2FR(vQk}* zvq+5L(n;zP?VB&rn_obTBnmuLJE%P$; zn10^wI2!GAz-rx|$fui~G!#-!dateCnXRJG7$+Vf^83E~!UgRa&yBU|v`U|NyIJ(M z{TKBjWs_GlO+(Ljw`TB$tCDXm7#b~&LDkfV1M{c<+}`eEU+#aIIg)SGd$Wh5;`BAY zcDLAQ-}*AJeW}O97l$CKiOFdXGm`?h%HYNNDg_;BdYYV(uESAG%`@quv&O6R-$y-_ zxyNeAvJvjzy<)?7xn!EzGHRtlg2a}VN{jGxm zPCgD2{AK#9Ah%MtG`B;Cf|k8F2~@1G`Dl+8_FeaDuRUhln2~78F@AjJ!_E9h`}cbL zFPAAB;KKm;@z0TIJ-*Xw=MUIL)@28Uga~C_9|@Cmp*B9nifPNtpm+|pmik-UUClO2 zXjon87`rc_BCKzPXkUQLRswCDI_}0exPdq+p@f0{SUs>ehrTmJtZ=M7tep!GLAY9Q zEbPdWM|8%gBL@#2v+LgD$s^viE%iPFh1YsMP9Sx^bLauiYjge%-H5HTxg*Syuz z&qOD#mc>d2PIC5D4uyYeUO;;m>+&Kx27Se&-YrxrJ%nccr!Dh{_r+%)L^{^6&1cNx z=I@2ljN8}}+AqrgQi99Nl^7h)jp*#|CVU8=Rc1ReypsmweUHeTb=`6+u2V!IYL4Esz8aNqYS;v!HF4fvDRK|ZRD2U zS$xd&G1p6p9Mj!yQE%_W0Zub&x3P2!Xz)x~MznNV?;JRXLQ zvrs8(`#bk!9qxj2Sg(i+CNP6tN1-r*@Y~1E6ICi;^8Re**{^C?-P}8bYsg+;Uenc5^>fEl&#-FxiVgLpo26&%vavGP-GKtgnXdimXT)7+c)U&N7J z8x7N+Pk-Im*vJ`Y(+Mo+pAF`Pb5aZHw^!Q?b#{6x|45;>vErubFm@HEcp2M?gt_ruW{A0!l(J@Sjjc3SuA+uUMQvBx?TDJ zH#xr$&#>d&tY;t21nkgKEnw(xz)aY^hLpCYsc5dP?|ZK2*;VQ2x@ggv4p%PYL&$u* zBMvAe!oN(<^cowaUY6>%RhO{2i$x8-hHeeV#@6;Q(($n=)J=G3xY#rf zw8Aa2uRK1zS&X-xs$B;pK*X69qF3h^!{E%hxH};+@ocnvJ+)x@mEKoH^IedvlSi!P z=MjIwZB*Ia>ar@kxO}8uf0?nd@$0Rw`JT43=wk)J)hSqc6YpL9>e{_G9y-{dGwtpC zZV7tSt&F@pBOn?6PBx7hlRla5Q8C3dO5jj??a|xPhUHf~yjrOzeM^4dL-p#PA3EZi zWQAe{C=aVN!X$ZwohYoE>obrU=}|w6Yg1C>KcjC2v(J3r){)Jl&eQ{Qo$Jje-2x~} za0jXMhrGO(4KD;O+^1Fc#TUGxm9&ZLQoOU=%{rthZTt1iEDqo5EtZPlRY)~^QBy(| zv0B=W<%{$n;T7@hk&X`;iZAv_Y0R_^YG`QqXtyPQvf&?EQg0r^h27UzceX;2`NyWk znsTg;Ce@TV;|-(ZJG37~7Ia%OZm1=d>~al%frP|Xxb4H}HSs#HtGBAz19|jpHl6j; zDi+Q<)V^loI{m_~AngjNxke~Y{H2pDJa_d12U$6^wh_x`skczn1hw9*~vO>Gm5!|w-O86WpR)}xwS-q&Y$ z5%cufrdN^51R!CtSPeL>7N8;Gz_^j}`o}>j1$7;u5~5i6NzFSmCx?xO^|6{URzZ(% z{OMWrMoUZ>;H3e*HfYo2o%!&WIaPiNtZL|O%FQU*cC@#@&M&sNAk!?NahFK>-F;Z& zP`Jpa7;vf^{rY>It!H1{O)5@UT+x>NQYDZDdvD4z4;wX(gHI}99pScsUb{>zUB2OK zoE};u*tO^WOPQczrB_2pbzhC1(7=+7`uCE(y8KjQ7H`zVMpQ+q;lj>B)-BOj9yYRU zFCK=Cp>{tJ_P`Ggyqx=+6|=rR9?1koWO#eWT1V5HFCC7j_T=EAfFEuwSJ;2a&6jR~ z-Ql8Y1GdRuJzVDPQi-t?qS5IRf6oj8MbyNH!6>P=)z{ong0^uSW+T;>5F@GpY=+gOr2*O}h< z3Y~!TLo?BNy#fi^E#vjcptVq*haB_GSTd;mziFfF{e=DvdDhqsHD^f zenl@kGovzErSRw(^%GntMyea4({ee-XvDdaqmy5Uv{L<=@k&zx%FO&!0_cy z&$F(&%sR8NE7?-$Y0By=M}<684oK|io)Q0+=@*Z3APY`SkNW5C%W{&8(^v842?(gH z_^&(hnByXSeYJpqt?acsfV&(TM?KJ)%BE}R(pP6M-D!t}9}nRJVR&;|c`#m0BVI6Ub4kGe7X z9p8U$X8+=HOnvtBpN-(SfrHF8`5ya%A&qhz>2INE403r6Gv@c~660EuoILxqVFRm2OP|_E zVI9`fvqDaf*2bYddoFqo-VAx=Vg}9&0AF+>I@AF(5@aCyr*F5e?mInyQ)^~c)?Mlv zcu4e>I$(=Ff;|);kOfZvXlv#-G`IWj3%}~Cq&HGbuLDN)S}^@*f9q)*e{a-GZz#mB zjkYH(`pp~d8DGnuM-LwCdV2J&qPz*xVZB3#9)^Z4$0~R%G40z`FD!a0HCF>Y#XP~T z(ho6e!Omw3@D@M~SdGDAzb{!b|LQq?*zTg?(K^ay1iO3ubWjU_K=ml(-8lJ_?3xX912;ufoY&E>R2)V+vSe_vg+N%)#P6v98jliwW-@M z+UjE{TY_F%rZfA9;#g}Csma&;M4`)m{4I=JN@m-(Q)FmS68Nu_`xxP+rJl)xKllEf zeOXNEAv|l(CEq?=KxHAmj3J1934&d>bq^ePAF(pzTQ975`13eY{ErbcG=g7^sEn&e zv!HJ}IYelp{?=1(zmzi7*t$@v889;Ge@C9t$i1mY9WtFqE&nJZI{AK}L^PzOpCGi| zY&I)Mn9M9gLrD#wOAb20V&GKo$M9od>Oy5~nu2gp9_b{lLjiMbZj1{u3p^C;;<=U~ zYYGpdU`a#^>)ySUfOtxrT$C z#3`12LARr>Y1sZh_%g2k$_v?u|;Jb2n9KL*LW_g?vM{Vv{K{|3Wlr7Nyk(0Fuu)+Jx3_^6 zMsEOnlNLVkST;`aO+%`7D>9OfaYi$QjqFIVPQ@V`i=iLikX!9+Rv1k{~y8bUO_zWl&rr!E>!u1^Mu0V6v4xbl>cW-7Xa^;o9L zylUm!Yo4AvZ=Uq2WI2|v?mPg@%*GdUDA0Ua#>CT!i}Yz|&G2pd>AaOiDt15{ zG@oeEAXs$X)~8BW_l(a;cvsJ)pnYx6AFIF{}*kQZ(^ zmc(oApX}=&E?ZB(W6Q>sE79+7?NxP9#39WN@W% zstvRp?Gxy~P3$aLvtZ{vc~@=;zsNfE9kE;wIAAWHScAdk$|}st2aUvKOVx=Xc}Dr8M~- zWxX95^Fk;L_H_IwRy+Juk=h)Zu$-eRusRy-C)diwk||$=svm3$N_VPWAsmmdlYI7*OPXg3FF4%2FFBfo&R9{{_tF zXVMN*20rJ7YlZKq?UA^=K-7QSKN2ht?BaPrikeRON3Qoa1LkhP;WT?us-84bQMq7l zq@|@5FuE(ZxUp{_`+|6c7j>R$e&g>#LW$@b1BUPysKd$?9&)#^)p>B>&u-6X?FfY~ zZCceMVH68hG1PB6IL-F$to(Cm%--oo(oqGrfBex+k48pZF7959owv84)>9xA1r&;5 zS~Qvxqpg1N_UIsDC;4^&v(}|D8I~#OvNOB5CzLX&ZEO z_ZKcnIa#Rs$yq+5gWbE=HC;TBNTORK%SQfr5M}Ayga;2+qZzcSp%8u5_4+A# zwzkQ^8w{hcApkn>1_SPFMd;Q6k&dbu;YEr?(Fs%Ed*+<4uwIxS#@i*@)!EsanPS?2 z#t?G8@wrZh*i{8{CAULpHrjeeD?*{(tDhdCz5(e}6k$D~s6!Pbqj;Q{rReEJgR%Dr zSwr~71XMPXRf|tP(m293WhXV$^o`TAZ5nvW?DY6}$#$Ks@m8Y*#{r`RkzY63)$AL- ztoT=zVP5994D(6WwXc|0`|->NcRtnvLb;Th(QN%Y9er#MoqGG`2G0xR+;+uF+8Mq{ z58gH{U$Npa+G)74?y#1LSir!x!3TA9t88@c%xpYhODXvLmqyN|E&FG3$8Ri^{zqDi zwpRO|Rgv+o8wS`lHB!c}uU*y~w>?Bf4~Glq@!)u`U*JX2#@ZC~lF>n>L$ zBK70#MiD^DfFfsP9D-|F6_8ad@i?SOSYOSGo>kA~6;9@5szjB9Jf%{;&9KtFsdJdu z(Wll3T;Y$2*;nfQ>-3)yD>oI!Vk>-=85_H{I`I_FhH+>);l%z14Vo{LZSl%5kFWw^ z>{Y5ga1UwD-?`vyG-)WlYq`8+n4dq;5gTN7U%r>wLT=PC!1BJ%EC$vu~>vnEr- zO!13Y7O<6A1iwza9c-qz*xln6cz3F_vvfg=3pCx4d4c&!b4Tr*Z+neU-J_Gey}kaP z1<%sc(^KBHPrH;kIOv$M1-!4)%4rV}dC}C%&h@gqt5__#)JDxs{tD2{8Ez5+fi89!iM$^n|9ooFVc13 z)wjdLd+$_KA^kw%H2g@&hKH&hXsW!N1HTgmwRIvJAmTlQFvMTF%0i#DU8h{Aq>X&aKrq8*;O&Az8BqC%Mls`lZhqWN~LrXzS^`I~GFoFV_NS|5@I#jcvn1*sMPpm276_ zE~DP?lsynbx0shbm5X0t;yCo^m)`zIH(6D6)nBwJ1EjxS=ORgZa7vR*b&sM#4=_8- zsx12!C6wHq*gE@;E9^_h#k)RKpfj-4H9A% z=uumbKmP>))ntE#2wScrOlVk8-TZ;U>@jl=A_R-k(KnhewW>^o7J+m+i z$3HCawaT93S2+3+)E&-=@ese-yQ7RF6-6LaR>2I1kF>ah%AANK8_~ZS{s?F$8 zHV4fB!+-sG-|*JBB4xq5_McK_W2GEKF!54~RGJ(J$d@p`x#C){&@a=sLZ~BIReoWo zzK%*7YYMUvoAp%61JAvtbQBZR+P-=AP7VA%gsawcpWi<4XQgwdAQ3`Tws!(Hp_J{{Hj-0_vtlNI+nf z?Qj2kAq-vJRKKUF$)(Y@Pwe}^379;i`tqDzb*oT6Y10Jf&V*~X)(*x0hCTl^n)B`+ zWukBA)cJO`@D3++*CvO{o|}Js+NJxe7x#Rv4VNt*WHKhRJO}T8e!f`e&N-9KIKKPY zgd(?NlH2cE@$2`uY!Y+-)df^NtKrT{!XtgvacPC!nTp`eVBZ1j3K0W~<+b9n?=Kfn zxfOSmNlK>CX3vViZBS*J;tT-)OuaM@XQI3wN7?%!Lg55NAl{@7A)Ga&RHG-pf5N%d z6^BwaX;NlIN!Z##`3sP&f+cD(iM5Ih?V6t zaD;)b$Y-EJG%?gO|C)%*JAeOWN*g8>WS(Q&wJ~3Cfx#v0yVG$h7w29(fgPqn12gZ_ zzdv2-!%=3x&!{|)%i!n#@w$I>J*b|6E;3$yXCs5wsH}rLIjFC+Afmggg%rU!39^wsQ`6DPOoLWoq4&Z#8v;gn@ zz_8Y``bXI$yMPPDN}2ar4xjdt2j{PV-zB7^)FJ-1-eF1sG$MI7$c}H#-E&wjVMV-N zdv;LdMD!m&!22gzQj$-l_B6Lxh`u>PU>Y!7af=?1X}>;mmCyF&QASV90+Sl*Jyo-@ zGW}@vCpN$fkvtzjsTD1Py6J^%(Xqd@0cLm*K|PXju;U~bJsE%FTJ-7VP^pej)%02j z3x;pgz4qW}^C}2wqhMxXiW3wP&3)v#gJdmb6u^?ABhPCrosdWw+@|e8+64sIg!5aiOc@}^LgV;MjiN_bAm=%gLJxwyQU%Tus6I}i4TvxyiT{MMAz14cmsTOseuz?@ zkXwrS>oZDF?qL76EQ0?;g_kHwcfr{=$qjGo3I;IXN^3QuW>QZvz7$hxDl1oOjK3>U zYX8qvKl6H@Q7RkIaSbTTulN+GS{}yfr+;OF&7~~e_~_Q3i6OVec#RBRn0crep%u4t zU(2@i+*zw(SrOqZ!6_{&K!)CpGn5sT>K0yxm^b_0_hl-;%#NO(PsD4@k>VN*KCho{ zL{CAiFe8$2F~Vf(EB*1}$Ct!b_T*t9J3X5xyS)mz5@9c^8KpPD9YD8L(FAI6R~_wZ zc)lzz{@cq+t+)x$zT?Iw8(Svpq_wg?%r&<#Yt}Cs{2l#)L}rEAWSiNJ!5_PtNjcUH z@?TNMxn^##ZZIwKs+ZPDZ*nLRMtXeHr2qB4Y#w(stRKmQE2u#Mz7wC?JwfFdn?$71 zpph&e>omwta>E8E2@MjH?7DmAn0?t@Q@~3L8DmU@@IT&xBg?m+O?u*jJ$VB!R&bs! z@OeAIC)DrRUrgp<+1*;BSQ6nis4?0aO)%>&TN#%g174?^auu>*XDA)nYRcn*vmcZA z>F;`p2S*6m7Fqk|Bpsi@AYC!_QCN43`ack15rYj4Q~;I8$1}-VMcio5fC~O=06F~v znC9<9wFCiA{#_uG{Y(U#O3NvjKtSM}Q=8m7Qq@_UJQ>P2%XMr^n3)HAaA(ll_t9{! zP-nzFC!(?ip1)dy6cHB|C_RFJU~wnd6K^||`R-~Fg!Q`0P+Qv{4f!~f!d(PUBZ1W# z{Rl{yCI>0}s#cK99}^#QQ&U?dprD z;oJAp6A_j?1p@D`9QrYp=Fa$9BRyWsbtLrSAl>8mssxm@v)lG;*^ieUeQ)@XX{v=& zo`S@HH{m})eest131o{vtw$Rs&+*?&U~ddefzgjZEmckCF|KA#5YT47BzYdKCt@*Tk6n(N*!FBV@D}~`jqcso5Ynw&oV@v#8 zW=+Miqo0j(?83z)m(E?cbZ+QyW_SJXFW*#~>G2CAzPwq@d-OmPpH(&L>!xcq#ujpF ziPO)w{Qh2nCksQiJx!5U+F{l*KJwG+&>^$iVY2nn0ZXNItL@g;Mo!k7bzkjYsr$4w zVEdryoIdXGDeq6d+qzfqGYl-m zZJ*G#ddDAO=IEcAen<7~ngvVv`~eA_M~Pd+G{^|C z6MbcLT0FZC_I??dS>iP{R00A^8>Rx){B1LWl%p8UpDWt=%G|Ee;U47){>q_Y@tfgs zWyNN7t?^s57Noi6YqOJs)Hngk?z?Rzs1BCW!OG*p-UU5dXwkBb{++l=g`(zys~h@@ zbm&Ju49kUX?{e94+B~yh#Ig9Jr;cKRSuZb5UQP;+U~>xtBDmpVMpFU?VTaTE$YDn0 zSZL7~{R%7HU1`m|JCfsMnVn2ZzeDSS6CGU)#m~%8-R*~DXE?^Z%qy()FvvIiVn^qs zPgM4Ijx3AZa6u+mJ>PyFd;EJHDSpLTx3%1N>SJxm zdwAmfUe?yVG$;10MHU^`YfDF#nC7`PE9O5F77eFUyvGc+@@@V1j!#?bt=g{6q8Z;e zJUgiu*E}v1u2|jv(sHcx)y2>|W?~x9bS3$WRa88-boiWCu+t?%ggdSr?_?L_xHq8M znK`dNaCC8`*G^G%%!Vmyqw;0H`?WvI=Y$2Ru}U4{O(racykBk1IW_S&+P`U^=Eyiy z&DFZ=5hp0w|6F6sa0SJu<&|%VS_eBViyu9qv1RL;p#}Za6@hZP<|RTSMwCR6##r20 zO<+fG)6X|bH@@ANJ7ewZLLabx7lDmFH4^6%6O)qm(+CKJ-b_%$l4nczRlVsb0#Z7; z@ZGpuz2$JSz3AIPHO51H19NKrwPLeviho5dlRh3T!mA2a;_p=`km?Vt@YM)%6Up+%mJF-{X z@pHoz`~!@lFj^~>jVE2{%4a$jHaa&%qqR^qy>N;5sf*@5dpt(pO;hLorCik^Dph5< zZrXVBp@7i$^;lP&t2$C?ZNo3vgMD8*z0^`WojU)Hm^gWxtP}EL<;m9B_&lRH6^UUM zKg+~4e6{UZUk3+)mAnLtaDOuI)AUeEsOp zU+<56CjRR<*V*mRDrTks7NtS8c0kM>oWeMfT5@X*-O5#^vSwSVn+meO54eE3vi0H~ zB$9)ilD$l;&jsX7UU)TCRmu85|+Nb-}ylwm9h2?&7yi>`xMmNQ^y}lkad)Fdd>_hE!3~`HiN5@u z%#qP~X#McmVI7;MFVQ;wE2{;7KJe!CDz5~F-8xM zr*XwJn5MEO^>Uw_#(QOJ9(RZiV+oUG?96gjng>LM@94xBTj4S`W6RdwY&lx=LD?Gz zUEUa2G@p66cK<|bpQKqzSV=4U`3j20><#@blf60P&m}}R@fiq3UPWvm4ST{pN3Uz} z-?1X=?6pN?_&L+lOA=aB93>a^ggLQ=$9wBtf^v=ysyQ8czoevPFv@WtBdcCNrQi$cS-e&`;NH^JgL>kamgC!O6Y?tuOS+I`?hm20%<1V z@b>16nh<*OnVD5W+?bqx$yhH>7t_oZj;%p9kvD zq)Zj)W|Ko(#sf5koY2_&3Xt(xDb+j9#%eJpIxJU?^~@KYP%eA3Y&}D{F>v&C_8%Uf z-mT7BIi@naDX>k3m#|B?e`4=4(E@G#lnLbhZ?ZkOi9!pieFGK;8#{c^3 zDrjVW{OzZrZwi4mqCD-zWp%tP%Wj=RBxm2g-I z|JG6`0UJGJ4XR+)RY6tQ6m&jw5t^Te561v$$e^I7m;iYIxKi+a<;JUdbZ!7Rzz2Nw4Ser7Hn+N-#ju zCDnXW5M6SmnSg2#@@tqRkdhptvi2t_F9V7pRtLZ0R9o3?{odnp5)vg|qHw=7H`)rY z04V4Yh;%#`Dcn~IiUYqk@klbpSR){eGG#9T>Y&{S!MNc)^XMC^#eeLl92;v-Ni_6a zT(ZYN1`*x*OkFHP_@g2}#?MKPK37lf@~Ql|6%DJsp-fFoO+7%XLB9`dl}ndqqFJ~- z0+jUgJGAGXH0hr|e;|9>C6PDgk3&<6hgzUK79;Fgs1qG@iek@rEgOHVbU_FkJcz!KKn^r@Ymc7fzFG(wXs?K2Fgm8Xl@n0U29C=teDl@Yiequ(ZK3$$SPO#+&!~)H(^{snd(T4P=9;~_-$#PF)n+w&0 zyap{3-H^m3oj+gt3$5~!cW(+!SK?o8%$c!jCL!~S5$tFOwDe+x04gIS@ZRVwTuH4LWz_JMFj+QxfrLtn5#dDjwqr9`C_%a^0Qd?m5Lhp?5vp~OSUI{A7zXh!eYuM=8|1ciib zN@?Qld?N1vf{2bY+Tlfyl^ z9mG;BRl=&CqTa(|?SB{2hycz=GnbQ?sL1pjOk@b{P>X z{)cLPc4ekPq35hn@5b!z*E@7T4w3Kn0iN7kRkeuorAsCfJLHwoiH>#)PmMkKo{IE$ z?kxVtKr1=u`d~H_Z>5~C8XN-2m}ZeG7?vaTEdwz*y~XP(%o+Pq?_IseFSyq6xUr?6 zGR1R|oVJ@`MAYf9x?%7QZIa*lrBqniV{5?)A;rzU=bZQQ^??8o%Q5v8Nwn z?omTJ#3Q)|$C$<}^w}fT!~T1=5eQ-@Q_ahLIbz91P(aDA8=9})+oqV`>@}n%xp}QU9uX!TD`UHA<1RQ%9W64}F(Dey z=sogQaaI2^znT9x08Vh4XE0tRKixun+(OmC3_y&w`s@dmoDGpIWUl#FD$2{{t#|Y07UI4$SSdA23 z={W4v4T6?v*>gSZOLRb+jsNKEsS)Bbn#Z4aOV3m&I*$kQj@rgJcq@qKd5^ewX^f1} zI-PeFv<-Vqs_-2h9lfE<{&fGScqMANq^UYR+Lh5#~aM(<34AGd`d3ZOey3 zE7sF{?XHea-0bFPW~s6dIf!%=?%OL@cx7})J$uqJMBRJl&p%ZsCdR}RE9PEq9=UAb zBm3fzHia{Yu8w-(prD}O_E^b5+2l%HkZD*!iW(f3Z3|0;`Y1nb1A$$?>cf^}|NOn) zcSzRvtOeXx+J~~}-o50gU(Z)I-VtseudWZ1wJ#ckf1wGUVBTQo!+;TW1p4~hczkB= zAFoPywC4CGjiF)KWNwrhAk&Xo;IZFtB#qBQXY^wkoDI)dOiqmH_YqSov+=5sSD9*} z-dnpJBRsm}zy3qY{yUpK>_e_|8%<3!cR1{&oNg`{D{Gl}-7;(t!3^+p-*WQRV;VAh z3y_5MaT2m^Yzx{#{$3-_U#dcioZ8T#DctNkIk6t@hvy_M&1@Iyc;_{4kN7rgk`!PX zT5}uU%w}z*x2O6MZtWLkxsuCf@}Nz{v5DVFd4!Quf@Ml68sRz8>$-&l*o! zY{N<#&lFYeW%s45r;jD+6!gBIeC{zhlvg-cFWaki+E`M6yv~uIId|Izw)b;_)a5c1R@x+u zMP3{FI%#>ms_g3V@LWVek&eaOlF*WMmI{m&g}16T3&{Ysy03EdeKVOxDc=!1I+7b& zBIN0sv#;>L0fh zBu_%g1?BZoWeZw*nl!Y3rS?R?z48oo$I$R5J%ZIJwAwB1A?faDZVKy z-<7BviCKqyzF^mgp_!R|-W^!<-OWo^dleM(@SzHPVPYkavWF2*nKRor)O#qtmZyxm0q~j6<&jI zqmgy~I5g&w9OU*I1L1{scz#zJv#u@{X-$T0p7=vv0bE`V;F=FDKaf1dLvy!y5_=eR8!>}K*UFx#q`sf*%~TJJX`LYE*r&dw1o?_i{E zhCzu9ci=3>4V!AP7z>P)l$HEan<72+6P_BEzs9!cso1u`eieK~#tPJ5jNDCN-&Cc~ z`6wC391=ZFEP6`ZOO>|LIARWKxMq3kPWiAn=-mP30efIWcsF*m)q##4hSW7<1)m%G zTn6rm_E^UoFizn3WTJGb2{7K=UA?H>p*itH@D}QTiG)NtKi56Eg7|G;J54W~x85?O zv;T!bSyvVn=?)TPNsZ7Q&IuYh28DUBPtx7n0zLBSRsNJFv!5qd+5TmEZQ|xTe05k0 zM-yA4``BTsF~dE37S=f>mUp7l=G^{jSvZ_;2yImCuCKY=F_VpO+LW;_}%`x zJ%3jvQIqC81UuClcHyD2_7Rf&qnn|RlS~iTR{!Fvl4I)?!&%%t_l+}og^7vfb2*wY9=&b{v9mD|8v4LhE%k1bLNOna!+ zq$5#hY)O9b;Hh*nYDI@WbxUFzCtsTMkYjgOb%f#6YVm)5nh}d$c3fn-slw~O#L#SPU-aq16H7wpRxv+vf#k=Z-@22^U?A-Tc z+uz4BH@itMY&6`l(hLN{9pUy4z55c6hQz?a#5l*UmN54r^nx^~ykm$2D*v&hotLt< zjQ=oMQCkpMB=vVnHmu|B^cM;*ttti&xk?sFQ1egZQv_BdS9qT$k!Fe$S z;3jwl3MEPzWvUbJ8XW8W5IM)%UX4UmC}s8q`Wdh<1#{9OLetx?UasBC<9#^;GsU~# zp3Gtv|5CbMPGm^iyyqiGFon7@y0c0QDK>MMt<2C_=9dZ{K^mDu(r&X#y0j!5uO!2VOY;qcsSsnTtw5j%uVxte_w}M7ff6|QN(6l>@O+2gCyn|olxWX^jk@0&4 z)Ez46AGHBZ5Ss>^%R>A`m8EiB-u$8AHgIff+OQMq&&|)ekSpa>I19f*(fCU1sAq^d zcO2edoBA$9nQq`%U(I0^23KRFk$396DQO9;3Md#WW4Js)MI#hsX7kXnTFn7)nH3|- z92jPF*%5DJ+OI^p@Qbm|&GxpoJ+{-hr43T14HeYU%H~-zUxgDM`}R#Up$wd+6n1@6 zTr6W6)9j${Em5!oj0A?+83~Dthbumud0evRE+fMqgBr5 zX}lOY-Vwtpyw+Cm(U}henKC(p=yaaov%#6=SiEMwGoOtt^E+W8^!yon%zjnzS=M5$ z0+Lk;`JL$Z<$dsw9d`+rg1FT!Sc+2>Hu+{jsg$K}Ir|3Fi#O2FSK4mDOKI<)PoJsj zJtA?p?fU41PypVT2CB`br=r|#%jVD-D?SBU6=EX_ZF!9>_)LM2C)7MCxrLf9T$WV0 z=gGoeP(O(2vFzoLtpd~Xakj-5uOfQ>;#pzxeUDWL<_0p+!5b@NwTbG)F1Kw7`7`!g zB7ly9I*5NJjmHV^SclI#<#*T^ua>Uvk;Dk zxu&Xm-i;s%m95Z|;Fnr=H}8QD*;b1pfC;eE>Fo^M<}qxAh)Bd7xb?RXae(7*I|!HZ zuXH_7yM@cs>iR*cQ;aP(ZZ}Xhredc=p+G&GY7M<84v0Ccrewo|yc6OPS8GsK^FCZ! zu@%abLlT56UhuMBDq?(p^tV}L#j)0K;Tm!;)@nn4G>)^Ol8o(g_uz$9IExF$KhgR9 zMaCdQdDckN5Z9w&`ELVTIHDjWs@Nb`vYNTPlfuwoNCGK@6(;baP8iYxdPFH%jRMvQ zVS4P3{zE7@M)z}x(Wof*T}kxI*~4(H(MN=YrzsI`V8kRHe>=f5)ySJ1XUlkxJ4reo zF9K|Y!*~*bI1*mvIB0AqUQ9YO}iQ6mF+}Nh)Vl@Pfw2s z_bEy!8>zol9^CvYgOa_;SyVByMgxFP9)KpeTE5<#~Z^38~NEZ_4(bh+c##o&(_pa9Xoo0k>*|LTBs2+T{w+oO!;p7_^cH-*| z)SNB!4r}qQFN+ebM*SZ%{@f z$Zl@kXOBb##)53^;PHCVUbZv@eef}Uspu!zwaphYmJA=qwk&$SDU99_pXCP1Zqw=8 zewlfpedp3-C2m&K6?x^Rd%8b34Hytb{>P|5* zwJ9M6IYPx}ybdatEY%>I@=yOH(bJ2eioX-`T1~#ipr>&X``@de=DKmf&8J`+esFXb zccAQ}Or&WNcBHY2@)6o~m(exNFrMU54h{rYm%EDxy68A_#nTJ)>>{ay;?Tb4=ulv< zebq$BZ6j*<=9V$aZzI`oLFjbtTuo;x2D=3;rUo-xwy|0~MrxPfa45~`%>2q6cG2Le zn?%lJXvW@lryHUlje~C%ETNFuLm8{;Qb3$-B7s)ZjLj6HaO%y%hh6WD{-Y_^3k05X zIMUmeGMLI2*NsG*0)RfP0+fdN-f6+hkg2McNiN_V*EcaqLWa1#%EP#MC$iLtIBw!0 zjL6l)nVxEOgLDp8bLlvN&GuyH6Ae)H@ukn8Q6pLmv?gU)CL1BSg{-m`!xYu*HW$ee z7b)s1SI-<4V$7ZXQb#08-Uz$;iM;6u}w#LgIW^4$R=R(5uMTaLELM1F*WRB?WxSRxBZ z*VnCAub^#zj9!jeRJmp?*?HH@JjK1PwuU)_D(*O`DLyqhu1Zz-xs5VZ9gskO)wdb$ zsL_n-xa?NmG9TibnU>A4HI#bab5%Hq(7-Q#iMEY+D_B3)+QIme9m>rO+j2b72cYnuV(h$%#ma z<_@EiA6)-9XQN^CgBo}40WnhpJbGkA{wNN2W7PeaccE+Yyz=WxUnDx(kRrOecv@8 zSL*)<7q#;l*!XBWV7p!N&G4fTRyxEf$D?T&J~`H+ow`3ynT0^El#X#4zZn!{UW7xX z8l;nDiOI-@4Y$Mqbc1vU8TBr#Ab<6P9)2XP+Fsnzdhh*g4J zf4V4A(X&o^-uJGvSj&`F=;@>E>?1Vd@qlwstZ+aCoF;beurf~Ub%1{s7VFD{pLXaHE>znEuO>Jxu zE4SdZddK>5;w($L(Nz)NBZkDkVDJECgt2(eXiI8#2r)cI%Bh$g(mXQigz889+nfzW zVq@-0&V$NCzwJ@U);-PQ`g(e;k4g}jtx*id+Om3&B4a4dQ=S^@og2P}@V>-Z;A%&> zDCPrh{(4A2SPG3K6jSrj-(6gC%a-Z^UnGdIwIL>hqz#PGH_u~z?=uXN!GivF>$4gR zLwr)wt3V0d7_icjxT7VaKW%S^rs;0bpMxYYf(MZhzH4N^kdaI>cG^WqI!>p$hs`r@O;Yo zTeolj?(ErR>#HLHj|prpILkmIfcF5Mck-823(vF$M*kmrCu_K0LmEaXdfzwyeTPlN z>RfPkFpkwZLLxi#zBoqW5!4Zqjt_lct?%z8M4{Hm_ZK`QR_Dn3iMc#p^5?gt8oiV* zL{(B;lx~5)2&xBfm}1nRK~H>JU_tRGN(P`7RoIHQn!lI}yGav35Oq&4%+hME@EOS< z*{opK2Peo?VFN=l^yLu0KpRQgDo|ksm6Tb|xQYomJXlU4-H}^d+y%g3U66$SFiU_> zd9BZsyFP>*Da67PgZb6>1^FQ!HBnAu39OA|jwdA~Y(D=tRzI0(|81>LL<%^wuR4)l zbZQXx^Ll8fOA}B2Y&z_6?u=~!Krs%Y{v!y^tziB^jN$KYLopC!=iS40t=5u7XV?t- ztp2p-Ys352BWkTF*LE^^K2@5Kaf9i8Y=F=3L!zMiBN>k?H#ZkzaQ_`Qa;QKR+wQcS z;^WSU_BwV?xjpo1T<4F^XOkme_`^E&Y zv>Q{j!kr39^M57^^6jb(oSyM1pgTS-4bfGLoR5R25v{H>BL@~-h=_M9;B!r!9njd< zzBc@*Gud*tn%ZTul_;7Zu*TqJKxUhz>1zsCCB0V#tPYbcEjiR^*1=t)T+a>F>pTG{ z5uy2Dz4C=)@3K|Pm!Vq?px$FSx#@r7*(a%V&N3k1_Y>jnK-7A;xK=$rkXXN?>uU^{ zQD#in7O@>eOS385Nc#6{oP)1ZkJN3G2N*1=tMjkUL1&t%HXK7XpU*M5(^_ow&6`{C{A_^-A8cs$R2|L$uz&+|I3 zeuQ1p&QIxl3DC#}+1+>`5wIpPmEU1H`flM*$`N@Hsgl%(UC%e!pNL305TjdgkZ2^t zH#nhB=I=DZI`Y*Md9oD1Hy2*CCT+lbDAyYt}NSub)X^=WxSB0+?E zbT_D5CX~T&EKp+|%yV{*Gn_`2uv^)o31vzIjNeA}{}*5Jc57Yl^SeOW3ig)wo_N$v zO&9oPC(%>bz?;=k|oDRCXZuJ zuNs;4n=Sk~7mnx7WT;=v#3xFFSuIZKH7Ec;&=U9P-)QTa9e8%9=3!(#-d&H45e9co z42JOhMOuYI(NUA7DuTc2w3MO!!IGZd?pW@JFE5-(_~9;C*O4YPFlC+LEv$q!b9J_O zl7(+%(?eHc!7H}T&{Cs1dGqYgMgIhR3~8?c?i$L-SsJR9kc-favX|Sc)~6fHB*kr+ zd41zLzxnV=E`aBM^1;Jvr}*m5qx1)*M{LdUh$2st>Q?-Nx-qrJ9VF?6F-g&r=l)le zv*#oUAu#SvP)hy>z1^T8$KyjCsY*liJFr-!fuNLYkD)7dG{`DkwS{;iQ6Ze*{w+G;4(8fJX}C zNwTPENV#G2>uxaW2wR6=^}04x8oCRHnB4WQ)yz`7_FvTT{S}~YT_K7^{{1F8Fii2b zFuasm6D_C)lfNk;r7VCK2nMpiWlT5YjV7p)fj(;s;t(A{F7j?_>|fCMVUY2W!SYNRbVi$=z8QG^4WzodwelW= z)u;G@Y~YC@2EpLg`0Qujw6Aq$jpKb1G%m}PUv`bX-g>?sZBq%d@Fq>VioA z+DWoCr255qII+hvCi5%BLuXV<4sLb)XA1G7&V zLAG0~Pl?^3_s2SQGgDKE*A^!(E%A%v&etU-(reztUwKKVem|q%rh$sOq7V#bP zX0^jw>|>{dLijC8OvL{u{;U#w`HamwSM&a)n)IBu z;>$)^ktnH1Nh#UmxX#4FkNhHBytV*iFl>wi25(^pSYk~k?1ygs9r%B_9`&d~gwwt1qJK_h>0TsG z=PC;BWB#xMsfKa~KE;Y~2Szi67}wC&2y?c&v4~{IQ1;TTeX={R|Ic}T4_*1Xjx+Ozo-a6gcwNS2XCwdbX_`*Oh&P_+=V!ZLQ0exF;XD#W z5{849sko0#CtrDU6SeM!T~=UoM+Gkt2aO>h*z&~VBuF)&`|^LVio!I~oz}nF(>s3_ z)rUG)omS4bESq!`1YH5O35jztiF0MIuz!Q$_|5m;wG9ho%?UQN{7(b}jtq@|JH_`~ z7xm`Bu4CHkuWrH8Con)0?MuKX?w2I;L)5R|eQE#LU!vIpJM{Li*DB>^$=}g1K1QEi z6@nQrkJe;Z^k|E6#6srY-^C(iaHd(*y`qW#?#uN_8tps8wnQ3C;znYozrVjwv(>+I zrcYO!ucrly(`ke2rnP+~*+YlFxU zc0bx&2xUn_VRQJV%v+4Lr)d_fm8?LtHbs7zIB=>0`SBfY>yjM zzV33{1jc;bgBe2?kYAz1W0gdkdJf%dh{OC;M6CnZ8=>)7xgHXvzcqSrX2%%W^<5sk z#4z}_ixITtSp@wHa1dGaCMApCLhPsYk!s(h=;2Z-+;>>GmNW!C(De$KGBl=8t;J6H zuP_ArUgSflr*FZ_ubf9`G8mx>O?OOeY*Fx{r->u`f1erxJM`Z^BniGrktoey--SYw zZ0v(d@^e`kZ?R7VtezhE`4Qcsr+^ewXa}VD7a`<@-2c0;V?;xyRSM*I@z{R*RZ#yc zZd(W|Spg}TlPeq`IQuj29Rj=AM5BcPI@iG>4vPdVg~TcrJ^ZIKc8kNJ*TOXZ)NM`6b@aWNG)2Du`fr;$nR@N(pYRVC;BNbxqr*+Wv7jp$w8E0*WcW4 zvg+M|B5KRI;av;KZ(CqyU>?Rk$B#v&=yuVK4&Q9VG{bZ;zbUyXqMNN37 zYG>z6_r}TFXuTDj?hYpoZ|(cK@Q|27w$8Ef9N|CqcfJ1x{};M7%z|@cm4iisRQbb| z+YhHg7KTr;XfluzIwvhWQ(vaJ@5s$GY+3~j`Mk`_jOu+g)}IjAX!P>poa{?6qSeP@ z4VWjlk4_Cf*UrAJ!VmBtK^S|0n%B7y_HEhmSAy#~3J@j0C=-d<1mEPH@Hu-`6iwu5R1HsmO;U6#n#Ex>0T324TacI>b+czt7PIUJ8rn z2n`+gieC!I4*G0KfK#$hUOIgxgSeVudz+B(Fm>}HnCVEbPfI>2mKr^wv$~Ut>@JNL z4P9fuXa5gewn)^z$NjZ*SA>6;GqWjSm)yiA{g4&>bX-CLJcr(NlB#{|rqyr(eWR~8 zGd>)-_*vP-y|6)3h`6Zm3Pyz7gA*4n33@^!IuODtX+1YyyQlWyZPy28c1)5B(}@ec ze=Is27kGZpxf>_pls|wbnc?=KzJj`%Gl`EeO@bK^G}%NZ{W9m_mEZInaRLLA+7|LX zE_;MLk$~ZF2nCxA6n^_#|F!4VU$z_F+XkV@XF{_r8-jK`CR^W74OGerGmOWMX4yd7K2;sC# zHfTx`7!Zd2iD<0rOcuHx5``#nQZLEGV`Y>%>8b3O-~BoI$Gz zZ{FURvfpDFe7)glzlh(vVNO>6)w=&qcj)I?)|O>wVlW8&-ifHo@y&90!!ZB--Gy<9 zl1W1MSyiySY=Q@^%?SvIY?<{dz$XG!IIz-)i0Isd>ED^fZnWozC)xtdeTaE4u)&^?#6xHuYA-}!! zf>L*p)?o0Zq#S@PdTP^AvE=`jMGjHRj8s?EuMlaVBPXhYFSa9-(6Pc0052jfRHASp+dbzSsP~C1^ zgcxQ)@%^Jt7dN1xZ)!J#9YQWi@%Y~HE{FLpH0JbW#x^XqPr~nmR#i^b^A3L;bs4|-|FNk!l6ba`iK0a#tKzWAs7F5Z&s@dM;`>`xrzVyG zDmfrYT13f|Rr}jiau7F0v$bp-s}7=6(p+sxk|`}BFVJuIp4f7nkYAQ-dB~R3=v^1= zI@ZBncGU!W+_r#Yb(q}>2)aZPA_0^zIou6h>LX^}n)pxGcn~{6Nj*p$yP#f4XS8ak zy)}9ZA@LoG!kwev9$UbME+rGOpc|4pH2LQ+Fv8;1NMtj}oxWv^RC86)LLC9Maip*! z>87Nl4!!9d5pZh>h5~LrQj+~$wC~6Tk~&TC8-uVenO@B_A|o0DFsGQuTVIoHgy9kC zLDM*H$F2&S_4?r*XuVD*-a{k=@_V$TF=V!%foSs2g4tpJBQ&eKy~P>1vveT;7)JzE zaQLO`pu@&n|&Sf_=JJm3a9UD({ zXcX*w<^#&H>C6<8=Uet2C=Ca(yXIFUo`pRFOIGyNY1>WPNU)W+MH7pnl8Gk98*g!$ zh^g&p^2EN&sI;^inzC#dx=KVnGXS$BPI2*I?N@ASDo<}A_|xlznFI(=x5n}d++k4! zkV6dW@yjMFq0lm_Cvg%RSZ&G@LCME~;so@r!1}D+o-4O?VuozbBRsM~yHPs$ksvrH zTZImSb5VKQ3Mqr(oofhI)<6c&V+RP*YHV6swX{bkszkDDGIZH0>X@{&^9Xt<#Rcy; z{80(-Is+7*rBSd^GqDWzVv3;{DoZ7S1G?j=jH6e1WK^=pQb1^DpUbz_nP_d8pT_V^YI*R4{~`zLXnF_en7sawUgUTxeg_O`rX9#dTCH+JA~a|p+y3NscZBdBa*jBML>=wvsA zY+r!6k_AbSY|ZJ};|Gsu`_HWz)5fx0b>m(G@1I&U1W3*g+!y4>rRiwWs=bFOXo_z->o{)Rl8AZTl`aR{(`dMN{UtfK&PGea@)o#JC9?sBlo(Su0Y}!ea@J54meTIe=s{hff z^dMz2D#?n@s~b7n=lb!DZ~f^L=!*zFn8-DgXsdCe2JY9vxErxNqLSb4g^bHtL5?1_hgm!3XbTln7X5 z;iJvZ2|vQ|K}mgmjNlTA2wnmctSQ#|93!QNEaYB=jt^6F7Fd2C$4c#zRZ3ywzf!GP zE`{^e?OJY|K@OvO6O{FvIOhGj@J%>ZhFW(&^tH+0%y?N%q?cBN0vS?=IqqU&4b1R1O~ikM z{pGU4uP$?T(guANt3qz+IsLdRD5!E4!Y!IPF=z*s9{GTERiEv&6oqC5BOOKIsK_i}0QWKV9a z->l`pylLxaf<&ZbO-!gVToFvo+oiN*r@@;Ska~cRxq=$mLg4wJIZ%+rE24QdO&k?P z_^Os_W!3n2Yl6C?G=c|TW8c@ubIB72lBmX}mx#Pl%At>E%tavyHUmt+SkiVX80;Bl zUp_?*GG7R42IRh)(wgHsA@ zUiZJV{wH@B#qO_eZ`}o@Q<4p%aGI}(Z~J$Gp!KUYeWFj2{o>xNw>6mfd9?Kd4X%g? zC)S&VVJZTx5m9}xBG0CkIX)^lBQ&w$%mh2#TFKYXHL(ZayTgYV7@wjO;6K5kYCi?z zB`i9^#K0H6{UcR&JT)1`{bY0$1FVwPsETB!p@KLvs%3>DSl;E%8Qr27xxT{etEdo+ z$7eliv_4#a)}Vp^>MUwhn;&@$ghJ^Q*}`%3+BRYIr04;cH~hPL6}RHQt5-k6=9N{f zNwAZ#)R?I$L3agnN5i3&AMhBZS&ix(iD-q@?Z&mxGuwap^2Lvp_)}WQF`iA{DI9(Y z*a)tDk1XTa#frw!awl0On_enz4t(KAbAqbKvKEX^D74R|lrL5N_3SCxzZ5C)FKxSu2Hp-2! zfGJ7EKc{TfEZC!*h{3WnD*jEwVcM z%J9*ko;Jf|7h$Xf>#^^m0=ONI{j6o9N%29+g(bu>6kDXOL4pp|RSg_{ccXiUp1Fr} zzLoRKdRK@^`gYB??AmFyFk7~8#fQhRj;qmrW zZd@Yvmh+Qd3rZ*+Yv4ke2qxz|F|Q?0d828ij9`f~4dljbQh!Xltp*@SU zhy>ni=qh|7;N&o|*Egtk+7_C0Q}~CT@gc+%@t(&H+8T!`&IK{jxb-v|BP&9mdzyi{ z)02PjYXG;F^?}+KmB`WjBUaZ}S;f#7!-SZ;J>=H08QE2m(?3=T>-iIr4#?ySMmck@ z0fZ6%a?<_~6ZZCN7Jqj?5-~iu%teVll>cPC18*agE?MIjh@S>%3r}m=h&hF7>Jg<< z0({^~5@oU#dsy1SWe4E~f$|E?A`7HiLh8ZjHLE^{K#V zIV59>(A?eXk9HWQcX*EZ4eSK&M@@1|cf1{`n%m4CSEx69*IW$Y0|7|(VDHW|Ne3d_ zf2M^5O<3}_mF>ja7gGv+^EIT^#r~^OdCqdr+M4N08zlN0GcJx_=8}+a$ObMnjKGV@Rs&+pKkGfA1z^xd-a(jDhu{c%<%Lm% zR1yl5aEjSr%*ie=(41tS0677TH}lqaT6`wo0j>OrhEC*;sCg*!?Eg=*A=jY%VpBU@ z!wH$&?Bp=ngH@Z=a`U);mmr=^N>3zg-rV!mumeG6dQsB&da44esuEb?mH)gfL{=4> z*{~44B6#O{^cmY)^9-`*eqycn$G@!vgKBpPZE~oA)@ME(N^L}Z*q=*b)%bD zZ3Beql(fr0AE-axTRo_Ky)Nl_S<`+39U(*XOQiTuw0dy<+RE2M?qSb*0*3KNS(P`J zd`71`#y5sXC^gYZ1;8*wj{}w?CzKiLnxi+Bka`>Ty5h>20}u7G3<1jFI>{GT1Z97= zM8|uiq;c@?BJ0ZtfITPMwj`9yC`W~*hOT_ZP$j1-#?keMlMsKGHgM_RqmF0Q01@1s zTwB5?Xf@xj*)kk(?0O(PbmvQh`@eoO?>z4?^Rgg&Lo>#S<-MBA-}FV~bPV8Jsx4TX zgcQ3)KkdD`W87pmb#|b6K7oa1u&FC7`SIJtwkXY*;`x!njD<^?ou{T>z08}*IF^Pr z4pE5)(-1H~WfJ80?sDqxyo5g8nWM?)osoxByCm~5z>kKZ8}3+HrSbYTBb$2|vLV0u z_SE4k1mFl&e5X&lUg-KYv0VHXq7pW~R(jW5yi{Fmw8mx!il7%ztiP%b4{Ja3ahl*3 z%4wgI?Xf|UOd)Ftk&{sC{9k(@-K}}MwQ+RF92pSSaBtt(KQKCgUIsZEbCmh$@%ks6 z`w;)ef`@FQ3w^_YK58cNGF?nmO&~A@aiE?z2d1x_Q&?bNXaA>7+F7Y;H+~*-|7w+J z!Wl+S-$HSO253w<`j^E)C53uwg3=O137q$vkOmcWCWKX(5y^L4TWuJ?&< zNIA+y>#Fm7$+eLk=o1L%2JFN;V#Pb3g5u%I5ug@I`UiCa|4uBdw$nOojXF$bcDyUH_3tN-4jQqmqCYr%|% zs?)vtN0pSaj%#inaC2ZSBvm)CLe?~Km+H|U=n=AnGz7L%MVY7;!?{o7@Fl_6zoswR z`AqeK!{mNPuv?oikyuAAHR3rd$IB*ByC@QFWYGQqpVUt**@|`WXr^jQm<;E$ADt zaz-+0-tr6Rj@c=>Xl?BPGM zY6z@~1=kz`#0#ODDf^~wQk*c2)Ann^XzbKS+-HUb0$gEc@+HtR*hJ}kU9MCCznAsn z9wG8T_$$j=_>BXyQvXtj$hKhd0DE>@ij%EPlTU!mtCJ2w7`RraBJ@I#8#(yl_BHB##PvG?uUCO=Ek zI&Z5EOdZ-aE?AaTTbrBBE&ERsXV2PO_LCtto_1|EWrAHa`fj4k?h9UCUMw`LpXKE2 z8p>G^;(zCZwL~I|iRU3^@+Xi^+?@75f6DI{daH>3=g-)C4PE;Gd;@KX za)1BgkN)|euuC4nDxy4n$9Ga&3NYd?1S2uw95Qv(|Go;(rlb@PSDj#}s3?xLeFdLB zx$;fl#zLV3eE#i0jW;f`%LKixqxQ3|(4E4#T5n7$S5V1u%b!e8aaGCxeHorJ0z9w@ zS%_KIL)IpOQlWqN=+Qmsf++7NCy&2W&2mR7RoB$~CwRrzCy8&~Y{hx#Rlg^~a91)B1!@;XeOr>9?Jux&1>HV-&tp1}Y}_)|&A zy?Voyl)HHvR2w$5VU%6cYVO;BIdYvQLO<75P{29OUOj8_QBgUUih$JEhOi(S8dkW3VL{yr3eN9yV$Td<*M6L01}&uOM-X3DFp@1a-$9nREX zl?H^=uU!#q*H7abd|?}kWDQ*FQLxmbAnc8?(2hF8W86#II31=u_yQ~L4m=bB6)z$$ zPfKCiN)?upvgy#{b@+?zRLqSi%CEj1n@L=l=8RX(+J1JRY``2!wk74I&modMU%Cry zkG#-0)ICcm_yKN~yeKFx*=Y`D@H7}6P8pw-G!?$@>G^S1^}JEVTc^QL=(2Tm5;wJ7f)y;Jm&8$dI%b?a8w zdLpTpmEws;y|LM~>29cJ>`b?~q03lLcejX*4ZpRubrNjgd5#{%p`*{r$|CbBZ`?8= zhA_LJGLPxk_d^qTedGIn)s~mv#XkqI5`}-Q+b#WZyk`hLeY* z8WSJ?R6h8oXi6?d{x$6rU+^aMWVrqQ2nr8^RkpLSvE2lYkMYEqa0&&fcSTsGU{Ozr zxah=%vuBx3zZn}F`~BGVV8iEji-U0(gI`uIFBuEDErh-6qAO!Pmr`s&$~s=_%@NH6 z&E7gL9r)ql?_c8T%1E`LBvg1oxK||bJN!+OU>+vQOj}i38yDUdctFkAm>U=;g@Vk; z^+Y;J>A>W|e9poKEiEng)XEaWV>rUvC<)|M7zfo<~PR?@w3?P$-X!SA>_l zlIdl_xV%Eh_+cI>$vYM!C;-PQc<6>b&-%-U%rD~X<{vf79hg<;f7 zeGFYVGuxuqPNGIaSskKFLvLI9 z=;v4tTJSR!nMj3BgoH2(K!Xrw2xtgz?j#jk zon_NmehD7}W{t=pu86yITedK7bo+sxXZ7->aBJCr|1`_e+Z3{6$8WBw>DASB(5Q)R zD=aKTHK_IjJ(7F#w8@3I&1i307rZ5w=c?1-Bo=gqIxyZc9O-iwjhSGte|>$&fh`_k zLWwFF&Ip8Jb}#@8d-rZTxb@gxGR7bwtb!W>*j*kHQOj4&hR5KpNCzRlujiHG;^I?a$e4}H&5xmW zTmQH?Qo^HqCgiyBQuNW>GyC`L`{&fDWroLsn7yuK&S3*iIL@EQrCNpGhq2&wl*!rAvg+~=IDJW&awz%bU$eU+ES+Jjv+}ZR zmO{C`gd%_U!7eoXqU=t&>LONFComi*Hh1~JmQ1+jy|^+xLXI#J>-fnB-X&)iiv-(CURx`D36)tp*t{6sySGcsEN z?%L+&Z5{<1Cx({MmfX-u*@hXGI_Q+T4VO3~Edfr^%YOu$2=lOQa>8?|%n46< zO5#{qSeC)+Oey^a19Y#Hxat?Kq5E(+L5Ig*PCpbSG&(vu41(-*vRs0v$kea^b~HKm zTAyyZ^rU|n_sMPCvx6bx8eYQD@E6r$ZVLQXSO19Bw|=cc+POzkU-zuei9sf(E36e91angHR11s?3$ZxHz^Pz={YUMJFC2h89hoB1v(d;8d(JD3wpPTi^K|XB-)tq0V zg#1e|KKC|5BO`j_kvZ8kp-t7_zIkBqGFcL7g07|4(k;Y1Kq@O{pf35;_q}cwip@GH zZm(wM>=9Z$$^uYN5fsF1%Sj{t?jqhtCcTl-bEy$23_b^=i?lzGHyCpXzXtOL8W<>S zU9xOS=D1skt(yJR9(=AksCrw*t;MztRa?b9&TA35b?XQ!;bfdTTeP@16UU(-;_I=~ zI7R{_g`#FR{)l-H%8u@Vf$E*lpLEkXLo(nxHN#~07)^n~FULEkH+){($P}Ka#)yWK zAx4sjRnPY-q1m6O;Cl5uE^eLcd^Q-XgQvyCTg)?{5o8bfRQ%}^jbbM=-%?4T5exwA zzyQD-la94C17{V41DLqCKe(CEc-**kIgQ;=ON&M(C<0BvMnHuzCVR@RrMb(d4G$YX z#nXrThVR_Uz_8g^8Nn8loB1`(&0a{|$VS@Q+WDsQ>#3-oK`>$HgODiphEJ6_u?%09 z51pHv(+sVce!4OCB}OrqiuTDfu&Iwp-4lP_44khzvn0*k)0 zWZ{?xmHqt13#oRjDGLZVd`8HGwHjz}_;FdQLPyh00-ML5P{-=6l4Kqa6YFIfw$2J< zVR`=U`8+g~?m>erEG&3MZpC(qvDZ#bw${b57x%%nXW&`%Q~R7<_^Ny97X9r-XdP6& zFUZPet8=+FHK`^eyyo_px*y(7wbHOI>7;k`+qc^oz1Ul1iZP5p5~xdffo5)^!<<2h z?6Y(IawRP{U28GIc|KpJvtBoJMVP}(tKAKn>+8mwUC8GafP8Y3{!rmCt^5ck3FP6v z@pg|Um>NBOeOeZm_R4BW26i^zHIQqGnD*vDLWjY{Ez6cI)4O(UBZDfQY!vC6r}pG_ zpL;XneHt6yKj!BzztNnXAi=+R#Jd=ruBHQO47{9M55*@aAmIJ`_l|W?*53n~Owz4L zs+OeSv?QmM9W^y|7{=I}d+CmJ>{)D(N@V)`rS4Q;+pMUMcELcl=lLFQTfC#wNVMea7nJ-+PUZ%v zz7wg>^;YuZLqvvUnP3*4l9L1TtasZUbc>}aqx^wLZ{4RTmfP1Hl*1*^sW6?sS5)~~ ze_#l~xc8Y~Wnim}b?*YEB1PPLk1!~dQ~QYaijhn0nFIxiSt^svrryyh%^I_%jtJqg zPL3R@f9~;%2F~CCu16+OK_xm)jW=8xZxrI3vVXW`VnWxWfLj~J%GVm3mBkYnp+8e@uD4g90+1`E`9bU2U4T$?2fP$RsI4m>OR8daL0>t?-GtxERo_ ze3AZ~A0pU3g2l@5mznOmH1(DCW$e5;eNH$HWPO!oJ>T zl0DZ~Wlgab`70)#OHdR0(~GijR`y;k9gzgO9VCpaJa%xcLM`Z95%=|%VN z-zSASTC&!UMoT`PfUS5l2G}qHFC^oB4atI1B zS8z6^S}7}b>Ay8@bLQLY%p=CwDv-oF)c;U!osbG!U$5^k1^2v2UernVUXtN8{~JfAMa zoli}>uxjuBJj{+&8jrlaZzHne)CxRbcPVSiwObrjv%qcJL^HGQc@6&AU9oNAfiE$u z|DkI%w`-ImOr%SR3aDS)00B; zesio_zL7x%)N1R;u7&xbg%{zQG>-)pxMpjMuiIGebW&)#uUvfB`TQlMhb%Bi@UH3E zGL&vBfcA^NcHNEi9oPgB*pw~vcRXDj z)RAeH-gnw&U}QL9Mq98{Gr{+n8v4_s~h)>Zm z_&|{`oZqfJn+(swrR}w~|MTYU*j}7@3RwfP`GxEy{xVqdh%$%b4jTEAbS+ zxmLZ;jE3u;`PU#hqZ1wNGl%x=Q^=2%Nt>&vrdB+t`;ORfh?ov=kDGc{Cch4;qFqzn zrMb`W*bcO61y!!9iM&0YhbROFa_p8V1CMTI`VVw+XqqS#hq*qr>hj8c-lOcaGQnkC zn-U{)#7!_4)0)P!R*&y{w%rcbm%?$?EqjLie0`(#=0FbeMOm$4^$XEGBhQMEA_8=h zFxa0G?~BSqDbtdh*?TowXwjKZ&Ei50*IpFza&a;7afiaw0PTr;AM@XfY$9Cr>C^ED z>Zg727xv>27U(?y{h@AYxfR&WCy-`_nAK|SeA#4+GBqv=ko{9rwIRSu(H$iuVE z&%c9gE`GD5WpwIiwD6py_XJe7n<+n@yD)wrrlwJcU}gUvE(Q5weI(CqdP+%xGeNvk z@=^#$^9>TSoS_(12ZQ|y9geChW4}2+qr>cQK-#m2IqV^~=pGunn4h4Q!-%Sn64sX; zA%$S3_uSmYQq}6saDx#v_R~+;5#rUL6`wGM#)tHFGOwr9NPBfhRQkFj3IJ7{9x9 zh}2AE9K*Aer{MhgaP7Fcxp&uy`eiRv%os$LuRX&W+IR!v3pUU`?AdC_f-r>5M;(0= z8wI`Dy*UCmmVf_#UpLfl3(61op&mf*Zjbg~Tv}dMkCmYDrGY$12iP>f?*{8U#MHzP zMT}qK8v>d@&oG9lTcLaS_*l)}4JNooy?Q76Y!5Pojyc{fY|F*EQ(dv_bCSP|{(03U z7q-efm9ZK9`Au-YW@8aJ8^ah)pmKeA@_0R6lIwhk6=eM0PoAs+Jc4(iiH~PFQpBEY z)^~whP!%mcIGIn#GwM9s7jx-Xw{wHQNb_aFYvJ;dZwxl_2OucGLdwmC=0XiAOtS0;Ji{rqM!F2|9&W{r*cYFhK`P`W(q`23g5H?hCB& zv8weNZwXOvBakvm?@T$+1rbfefy=L{NP>MMYlRICN$9%8zD)v~b9jDs^#aBejaxpf z_?~4$5Bcgo{0M-AkA;QC^RrgooSO@E4?p=?r6{)>g7ewI_4oean92}ejA$ukcq_N{ z7K-r}L?=&y3t#X$z#t}0=51n~j;^PscIOZp>vXzimuNXXJ$(}*0kb45*a&~~1E|qC z_9usdcLKuG7LR_U;K5t{sIYBG7|U|RwgTU26V*g|*=R)us>j7gd%8D=mbn0k6AMVq z2?+@a;3y+TZ>LZB`T6Z_9eem|vsZD4JO6l4)3o0%rz0+cGc`|0vX} z3U~K>qgG6Kj1mnEc*(2$BbT;isV2HAre`$$iGoNN`JZ+iO*j^682VLDSo#$I4@Rr! z@})p|l@4qXdoxz=+oZPt!38DeDPZGgRaDj=L>R%asDdqTS_AVoM7T*XQZdvQTqjOc z=G{m)2ysc1Ha8rxHpyu8_3VFF`}RZI@Q<(0s{|hVei`0fs(Y?%%D4Mu<=3wiz1fi9 z`+n>p(2i44QN=jj*3&HnK`thq8Cc`j0`UtxEfr?j{6C)sZp~YA4p`?Z1fX7ZCca*2 zci&%L-Q7P?wiGMOhoWG(l4X6K`^cRO!OeYqVc;NunJV0sxo{!>t^dRHmhSGo7!{{n z68winZJ%eE_Td(oZMnDYU}QWEgJfog-zCU;EXG(8Hn+#l&W`zM<o`GOJTtR%CmW@QSf-NgRH1~%5qFU($6#i}3`dXaa)`^@2 zyDz?B=%dtkj% zxwASpdak08`s<}qQ;bG9ejD)AH`0HRGm{ILH){Bw)C3o2XP|wa07(Ph1>Pu<*4UeP z#;vT&31I=jN1>+t(LU?aP*I+vn>KAaZa;f`<*HSib9@2M;a{})-bF!sxekY%?&{86 zyTmOlc%d%|m)L*8=GOuA^ID!~0@n)CR+!~?ck6w5cNLGF@a#_~W~%a6Fy1aG2|rK# z(=z>cdGXw1;iK2urT(mv(rU^iO5*DBBOj&rbzQl!ikUg=+$-e-OTbi=>h8T# z_kn1H2-@VIb5l*c7<~6P^vsg6S5PPK)1km^C=Rw9y5b6Sd{35ACWxh3VNT8sgfz)8 zPf$|ReUy zO5u?*0hV4b1GB|5DWB=B(%&DJs;)~P=`?ntawD}%d40Vb6xC+u8cO_4ddHvb+C@w2 z?T;yGvxhWpqBjA5*u2a=BWzA4ZeyM5%f%Ki=6lv)m8Tycx-xEsc5m1rr+EVc3`VDM z$VvjJ{p%HEvlz$PSeThBHoA$@E$qTQMLc(K5GJdY%}w{fRe_0SwffDQG&jJ|M?TP&5I@DFFa5F*?l{&^f&MK=eq*a>gs>LpT##NDw?u|Kf_h8#`yTopOymn zOE3v>#lR$ne_uwA4WFI1gbxHMg_2n7;q6UpHnuDl%RNjF6!B&>zLX6-b}N}-?|4Mn z2t>Wr^7=rIi~+?%00)k0r%N8~twZG0@CW_jC-h2fh)>yq{Hh8sFuB7Y)^RfJ-@guZ z621^?P9UaKZ3d1S=fNKF zIkE&mzizOCOf;0R;EU)%&PWQm6>0wiz81(93*YCLSKi(MXa~pZ7L53w)!kiDpqcz$ zGkNcgr)4X_VBUW}34KtQXB3YHe?%GPJw(~XxweWzPu<>J?4YoIf^5j9p-8s zG$1r`yyk#g2CM^+HwdK|llf0W6CNY6={Jv4QSbxq+qNAG210sUuRgL|$M=cnXf)4N zJ-i}%C>(%tpm$A8O?N*_o93V$4U3C2sF2~a_&VncVj5K9)(58nj*vg)PjgETtw+1C z4RsASAD=sXquQVx0$cbVeo^Ihb*^XkE~hAE+geGDND@e#+r1Bv8lX&64j){0*}H=J zxc}fmYzj>Y2LjxRFkahetzMyN+Hbq<2#|=>0mcaeR%=SK%~>vR9)Wh%Q|#8hyd&8yA|>G=-fdtLs|3e)hmg^O2_<`e+Cb#!xEeDN0u5WU$!HwkLHeZDabQ-_t_Y65&(SWyKf(ZglMM5L511xL z;3WX!*bSoRE6|u z(7wUYZ8_RH6e<_Q*HODH1M%&uKLp^hSEO&!=lUu)0@S)K7g{#VT4_K0eaKwIr^8QZ z2-S=wI8Cv7oK)_^hwtD?3nx)k3pFBH|MC)vIi&HSX&b^SXp@= zZqA5V&gh=}hy1?WDRI_YMm3SQL@` zR&UTRXL%T}i4QFtgKoTh^Jx~cGxfty$f~sDI#U^$lKYo#Hh_0bPNqfRZFTmi zQ&m=WN6S*;ZQz5ZmS`+TbaZvKASMG>k9E5cq9-j9)DI6o83cjf! zFQRFn(^uSsFpf@vdj^uZqfPcR*Tv?f(}XIoz8G+1?GpnRuZ8AM#B_V2B_^xxqGvaF zot{eVy(Mel8E-q@yCgpv;u4WNN-!x5=R^bTW!nCcm~PruU!A;;p54;b5fwpu*Z zN&H1wQ54q|y zQgSxr&RfVd+ULkJ3?$q}tGn-W0CF&1c>ihr7m}5kQXn#k@(ArONyJIY3GS1zJ~eAp z({HFq+jVwzwejXmJ1;l~3MZK96T0t=og7PK;Mn z+ACMoQy2MHY9H3X8`P{KzXqyMqJ$ZCygkeIR8%gTGiP=qJ{QqU{It#ryWcB&_Z*5* zR5hp3(%>vS03*eSAPt8}1@H~Zr6i-jqczV5rYm(cqmkOH zkmcA^%wdRITt_Vh+;f5y|DLA}&Ty>?$Na}F#TkInASqqY88bDsB*P~N$^OHDc{oT! z0q@7qaYua-GhOau+YX~-UNDYCO2O3dJi?yqq$baHuz%=L*Dy^t0ra*6gjQhv4qp9d zJETHD76o5xYilFKCa}vN<{79B_O+%WcB95|OF&7478hMDa2VH~*&SQAim0dr*ReV| zIW67;)lhsnOlW3ira&li{KOswm4laFEqAR6=acRg@%yl+h%wY}Rry5Kv;Yhq)}W$( zFUT12=g6V>lC}1XgY+JkVt$uu??+@|JS?EY?fjIV{*i zUh@pPCDjwl|VsC5A09Is8YPtAkD{$a{jFb{b$vCwig z9maHLZO;5XKq=lsD4R7uADG(I9s3$$p#tO2{3|1Ze#FL{Yu|Jw! z4aJ&~QDPn50p5w&w)HEQ5oeiCm9V|8l;p0Dx(c!p5k4%h9I*>;Elr30?7m;ooi~r? z_hCwTUn|=v)L%=>bL@tNcwJxLn;y!;?n1E;JpFE|6K#_fE%+GS{rxC1bsP9NHvM~k zJTYkq%u)Y_o6=j8Jt&OwKkc3}HB%@g6|KF!5KZuOQf%xRkfQ|#` z29y-M>?_WwhT@D65_R0Fw~8ZKF1$RZF8bU>GK*muiWg*|TkFPUrtB8d#&0*B8gry) z$n_~t#c{ZlOIt}U7IwEb@n`s`rzsbl+m3S^sA#Px$=1x`C2)t7t^hMh5_bibWxU(< zIK55J2xuhtT4i27av1w8dDC!;s!u|hL^}E<^`@+62jmRsiZBO}AH!v(d?I14G0Wy6 zgP)1AVy2|r95xG3Nyytpo!ZxM(nlgI20|6%Q?D_s#&A%g^6s>)P|3Ikac7`j8ugXh zdbH2{5V8WT?w>LHRU`N9Ul z)v?vw8L<=yydGEJ|AWq%LU3I(8m1s21EAi$F_&U-S5QOus-G0}k=`D8uRBns*IPTw zH@@ZG^0Ex;90%)BTFI(#QCfzBN{}wpEcHF@$6RV9YA@u&XB_3kb9m8syXIn9KaCe2 z@bI>F#g7mKDtXI-WDYrT>ol!P_m~r&ddK+x7YR?FlIWws6_6(vWRAxM=!hkvIv7^& z>p3^I-lOPrkDhmwxsTUtpP?Zb$8gK_+6XXu&nFi$ZqnCXIwdn?IjCzFr9g&et>tZ| zIv(HdUjI_`Mp9wW_5+NIX`i(odH=<)GrEZSPkYqCT|&8?3oH@Ss}K_4FM9e2zF4LN z{YTx$p5Qq|Q5^<(!uLsd8C;Bz?3f2SLy8S1zUf}9D0bqLyiy^NbMv@B51q3wHp)fu z?Tsv)cy^{eGj3ugns=Ar@m86j=%Z#K{+P#(anh7fY~NkF?950hwLVXsX<&RfJ7Zk6 zJ7k`yanP(N-1oG>&`_0Rw6todQP%htG~AlU^xaQQxgP9!6CukEjQs50yP(wy@Av!g zePp%v8bS|Alh{$obUEQdd5to|v&D1I!wW)riGmAEcUKY+8sji`4{49LS6XlM(|h^q z+d%6Y?F3JCsSU657QFfFBYIxk$z_7l1ddp(Ys`)=627960~%ag=~z}PrV000BIZEf z2Qd(Pu=`n=?GFS51fHG~Ix+QWspJb!FIT;HlC|q9Tz}x*L8~mn#B^GzWH~|SEDy?@ zumtjsN%#$6x1_DH9DZTag*l{a^a2zyF;J*qjmYP3|k{O+uThpB6AN5tzZxNpMF+4FYB{mb8!7%23gn=~DeD?6la>1l|_=^{7 z!EJ+oKF2N>M+``uqOeDJCCKpV#`5#>^1E-?g8H0K9~{svKx621{Kqe&^f3M@acdhm5ipO67CGwx8R_ty=Tl z0%)y9WCEoWLX1Fsv~IN%3SFJ9oS^A`O8=m0#E(hRJ~B;TKBJrwptyA-DZstAn41@9 zq$xws>Vf#ZgaQE)`#_RUcM-c ziS<9C0_M$_>D{jYYreuBrOR@?u6KKHac@Sr1Ja{;O*FAK$W)x3lKJz)I+3v`U1SGb zU?Q-EGTku;0-@e+{YRgs6fye09@IirZl%FWI17*Mj`VNBfZR_*1w$mgfH|Dr+VMNErnEpJgGHA*#*3ffOmk83j9$*-5BgG zv1t3a9_nuaXl@9+7Xpj61K{Z8gr-S7iJs+`yNx?gWo@U?%Z8$ z{9KFg)Bem!ft(h=STa~mZ5^Ff>=kxFM+liFnL#vO2AM*OoWry)^H8Gw_D z5+7@66_D1BFcot3s6jq;V?Vw`9nOq|!X%h7T>saD@q7>7?BV0bE(oqr{1J+p+rUR+ z25PjSug%coUe2)>#GdCRqP2FCkMK?LAWA6&0}ePG+mld+_7pPYrIaLKi{w*cNh|x$ z(a?pRSh2PxoPV$>%L7WZ5)Mtc+F2dA^oms{Mc$NfYkd9Z2{daGr zYPuz}iZY4$%6-3po!`Wjo5c%>o;x%ey$R4LbR1@fHC%F%v#} z%R%M4U@LdRvTyJH{dsp*tfLzjc=YScMSc<86KW1~X1I2mxV!Ogd+in?Cu*g_H~UKn z0W^h3^Ggt~oNW$DZv+8181lD(CSaChR^-_e200iuSs#G9ldicE9s4Gb-^;^7Q@n)d zm4c7znNpJak`UVwNirWO?M1(O z`PatAgCq5Z$AI8LEO1%Cl=;yNoXU(VyNTk-_zK$RH@CW|Z+w4q8z!>oZ?#~gMjR8D zegYBZbOU`Rs_i%Sb9^_NGS{F3MIWgN6A?nS-Y*|d2HX~D#gGJ7Z5;5uQTBw|tvJKE zd!yUl5qJ}Cg~I6`+9QBY_i?*hNTROg;LIA^i2D|yw*!`-y`0eTG%w8aLLcUc~>(uFz-ndqrj8sokd_?9V~F{!zTpMjY6f3WxF;aIPG`~O`k zjpouMLLniG(4DC=X3EecRLGDbp^&LmrjVhMv5c8gA<0w=p+bgIh%#hKGGz+i^P<*X zYwfkyv-k7-p5y!d`&rM?!hPSL&*!?X_j#VL6MJKpqh;9Y^`KR98B9g#oBLJw zDWui$x77CD{o#tv#!f(ZKtV0JoTq_Dw4T1^72P961GC_CT#_q%zs3HzjPt#{ z7h^ZL%V&17&(f@x`cO#$cg_^}<4NwzTbwI~?lts=b{rK5VhhWSY_uGS<;wYDcNc<) zge~vZ%64uWC(prEuWhD+bK(2)_B%CvR^2D}Wl)58fUJHKzw3i$8O-WL&jEKxSzTbt z!`1GU0NX1bkdRC3-`xjDsERQye(N#&SwlTbfmhrwL|;@BSq}g44jj3tD?Rl$}{9l#A zQ!H{*RpQhKx0X>nYOByht2C>c+Y~}{a5HmNK72T8VTb5tks>_I`==)QjBIUn?l;L| z)`qjC zTWklj^>BO_?qgfJz;u`@=-4wjdq~nLf=%rs+J8}9nG>&++zub{o#bSnLkR!MQwl6mx z_}$g1!UsuGAqzbU@wLd8Q$;+lSq5e|rmZ-B0S{nw>eL?L)4#ARK6>iZmiQQp$H>v# zN=wt287rdQsTD2??~Fj47o&4>wtspQoA`>Ev5z{pY9ug?elz;;b|C1~D5*<|4B)lv zSJIZ)Y2;k2v$h$kmSO$bQ;T=ajIyhr1n38^2=dJc<%`u^>s9U85~bCO4LkC1d~8+g zeI@bhSx0S&Tx6oPt>g4|SdilaMFTf6c|q?oU{hP9G^`{!^Nm6yBpx7ghq%fds5}&9 z-;JSH%1w41J-VLq0pyRpij^xx62on>iLR$rc099%CGr9Nf#DPbr*5mPYZOE`6PQda?yv7sY8JDW=-;VX$@@?Ia9?o*CHo-*LaF#C-u1<7S&>2IKc@ zkW5!)d-)^UICo>$=N8e+azjt>rXd70=|ZL|XU;_5y=(c*_TJ0iiM2}H(WBFNY9hRX ztQxr$(qs6QF;2!8j?tL#e(s!kC!&=hfzOO`fBd7|e(fIxRQk1N{uroCfjTn#F3$dQ zR&q`EgMpx=^?XU|4)APea&EFe2_&VSc? z#;!W!rcbYIzj9O=sRD_qhJ@J)0fJyi6o3A9RWx62V}doU$fhI%CeSm$x!p**%^1~9 zc{qT2OREtkbqlamEA1!nFBDfYo7F<9<(x4T0YBx81?5q>T zZi}8EtK+$Iub+;**E{LaWti1~)YJtmg*a$aV0n1<_3IXr1Il<^FtqLSYzmUa8va;{ zXlwP3DK_hz_e411Ss%@UCW}3^#qbZ3aF5-V+221vaMt_5J>&i0$`OV82*dPS2aaehLsrX(Pgu~xx>N=tzz^4vB z+FL>(eN0kGj(b1O5D;2UfYQh|E1`>;-b+_0goAFQW$>k)>xV{0V(`odhz;o|*^I&C zI8N}D@vMT9Pb_^ayyTF>yQ}B5YL!Cp=0Bz%PyYw$$M|O_#C&WD;ee{G64{5#9L$5l>tw<42GL>$fb98pzxQjo&OTuT%BAR@nu<)U0})P(gJ5QW;n zX7R4jWOC42cmrCfooL*mhV_&9-mQ1f+f9z!aZp3~_Qnkb?x!o5x5#)OJ07g=kw>H z($a+(UckmdOr<%^lj?1CVf0nzYf;&@X^NfB z4$irl-%!ZYcNEi)*Hxjh%}<%S&i3JszJ~!kR-Cz22bW@->kp9U^V8Fh#J-?P`U1!p zJ{d4KH9HM1s|er5EOJ40+*#_siYqchSmzt)$;A1HWyK2bU;O<2TQM7Y0_h8hlm!%Q zQMB9c>!uQe{j|RvC#IT@9EUea10ow@#4jkgqVL+m;VEw1;O+>M*LFZ*K#V)#jz1oV z*l5yFpb215dyCf{!;H6YLpVQ!eHXnfNJ@Ha>W42;K9>wk@PJu(X{Q88wA9d<#c4lkz=+4CyNpxdseDEsQ+i%I7x(Lb z#ERrCrxl4(DTLv}wv}%gQ5=6JSFHisaET z)eYFaWHl^9hS9$ys9K#goXw?xZkFP=yA-Bcl2I!*h2&TyRmoh{_&WixVg&{6nWuhi z+o7e(g}^ih!H=vK^12vZ{B+X{oyO5ok!*=LkTb+!P!X5osB;9QJ)G8(PInDHTVr(u zs2%TLOt8Vh9^4*!|8nzoSPEOFalC^e@>kR!o&re)elhv)Ixvo5sWbvGN_hBYRFr@7 z_fO*9&d2TMJ@@bM;)$eCaM)A2Shk%%XePmMzv+IiyN&>!EmzAUcgzmm*I?*w{L4*S z{R3*(`=Eo9IJv`x5b31Nmtf#nuc1P1SpV*HC}N@wSsGVD@newWUJK;7;JPhW zn=b}+mW?27@N4^NuT)5tkSv4y4I;7}Aq2oN5rae^4!O@D>(JkLaS2|D#Gr0GXyo4w zOCNz#%R6K+cPN5))27|;+!s`DQ>g#4HT9EDrMnypsYLtd{NWk<6#bkP5YMo;!`;nH z?}83xPd*z)w)5QVW3S)8_r;xM9SQ8-6^H+)L_Fmb^UagK>&enJs~Z0%;SGH{l<%~S zA@AOPjs5`3>dwV-Lb4-$&HhM641N1%Nc=#Lpp{~I)X8>bS-RjOgt_1&)wV~m9PUwD zv!VI0h6a7Odcxbo+S<%b4jpU(f=x=^BuN3J8q*j)>W%UBf&OciGjRj)`e`=_bG%nl zm;X(&F$5c6=_NQ$k zzpAcgs+q$rj_I!o6Rwg2-B%GrN&0HaU=2;TKJKH?I@56$R>nttnC;qPU)FP zAtrO$Iof6jzYYC9`z9V%z5qfxCWB}mP`}vG*2Y^MM+hUzwy`?!N#dl9Ng>kdWJ_LV zSN7rsb6RG-5MzJ5Oz_k)4dYY)Vz)3IgBf zla=%EOS*nMWQD2f<2kextoi$5YanvM7^+#cp4v)gUK~ApcG^Y=$Zd~jU|V(;`#mzb z``&wF%6H_*l0eCP1Y;I>K1_COyCK?c89lsv<}Ia{K67S6DbAu`^~>5G55)9RH9{_^ z$?2-p`qf@?CPgfl2f}XYY9u_jI-n}HTk!=JJj!b`{;?_eqgD-GEGTJZ>|#gSB;d@( ztIb1x9WHN7H^n_*gL<8bjK?a^rVqQ@K20D)@p?y4bpxbJWdyVTpJclvtXu)FZw$snvXJ1i{tVJ$22ZAO@1es$i&Aq0OI*djLAwIBwj z^A4QuBUuewWg7Y$a~-tVVspafL`90i#563rB?ZJS8*#+C8eh6Aoc4NK1Y7kk!y5Ak zev$UbC_xv%((i_?w=ne!D0*5v5x+P%=MTR*WM*dO`7V}^uAS>)Y-=Dj0-+4RT<&6` zE%HF6du33kR6YW%J9DpVxNmml*po~XJhNr_v=Q&9Z1=tAh>qY>0VgT))uogsCnod7 zg;;QTuK9QFIGWW^%v?I6>+kV}9Wok{J!ErHT(}S;CTw`YQ|-U_xE`AV7H#$>i{GMz zd-|qgt1>}|ie}cxS7!5rTNT2ivhRZ+e}=1_xXr8FcRn__sxZ%E=Xw~+@Fo1~@r{69 zu*2~2{3!_lVA#wMVN^}SK)ME5%!k(zOCMyQITWx9!x!F|T7W~u%)ubg$h1xh#e2&T zCQ&|npsSqc9T?vBhQdw3d%8BVUyB)TOZm7{_?F(vU)y z=q2^&nT=7PF$rAP3%&2Xl@;EF?m%@|jbY8h+DH#|UUBc<<;1dk@L-~P6)p3?%8(;W=x zvD=|guxc){!~2o2QECpmmz-!FjkhVbzDQk$Q6)Cye;-ikcZ8o0&##scMrYl_J{f0j zhQB+ZFf)8Ye1r8Pb`vdz+1Sq{c0Dk0|Fc{s@XvCY(tlqryM5!ak!Btm_&BK1DY3EK zA1f;HgN5g}9G)KEZ#R^&yA7xL8bxZKtXQ<-U9>mkURRi-=7A4^ri$^wtQ?k&h$W@U zZ_V)kAR-uG9R7oq(*`&are8z}Q|+*YRfs@!U_mk5am*a81`(&F1**%lj1}xKQd@$2 zd?DWTuh@#-iAmVJa;bM_Ay#pS?@}<-Ke5be1ssxD90K&=mug4KjQph*+<39UQWd-Q zTj+0XoY#xIUMvnOmFGLn++o*P3)N#2i+6;x@+a;u*l1;Q5Rv2>!`p!H$cu zcp1mGu1{``5TKIK*S1|L>LbjuBF*|3MOZQw{gg2z3^G88wA$V&2D$Wi{5 zrVP{%T*Ze}^I?6W$RYwrq%?9wv5t6fvO!GPd-pG}+Za?v$sGP(9mVef0d(G1AT%G|rmR1OCORFOsCOR;@LT!hRUm%*k` z#aLq211!Q~?O1B{#lkvU&stHYGgV_uxOBez4!xW+YW;2L``DCv=*%-|C}b9zn5fKn z{R9~n%<_grUoce~9~*Ma<5YkDTc$EOF>wb%>rfZf5Wh zz3_(gxu4<5!}9X;vD)7*?i*>vAF0C&Y z*(M-vYM(xam|9-I+HdCkW+5b2s16*6SrE-c9%GT9Wx5wY7iGkS#8GDn7!mA{kG3rxYOn0qe zhmwD5d`v#vVv)ldKe+bcoVxm&OZ10ahL8M~1i1iUi{`7InLSlCo&uARA`4qDPU~_K zif77Y9TT=lx=u+#islUPj1JI72YNJqDPoQs&cwzG&bKcPg~P~@jsnP(M$F{UOPEKV z#eoh01RHE^4t+*}Rvn7M)wl~G1x`zx?no+>NMQ210!XmH@2B5Ly_S~Ap=`Px zw+!w~jB^OMqPz1Ek{KYf8c83v%sQl+mLUI^3Q4Me65xm6h}UA!{hFR$l%i2M|9m){%SN%!gU7 zPA2y07chSZ+s`dlfm!XDN&4-3_ZTQ&fn!RFF}?DS_?j{7Z!eb(RA~mU#B&3em}eqW z+*a+`;8GhTmTE(>E1#op0+4YOe&c})>lTOMFk!aM>2cwXQk^9b3}ZJylb({nodk2z z)Rar^^wh-X&fQu_qrqhlP#gG2+aJ&D{LHleB!-Br`x9M~#sjR#-~AD_xDPVq@BWww zKt8ETX+0(Jq2C@~#9KO(=9#$)X08O)ClFLZ?U2x#L&4O8OmaJEwLl&2JP>FFMJht{ z7Gs2R4uM%Du z+}et#LDuAXjNL?x6ntLc&xyNgBY8jGmE3^C2GAutEPKpBU&$A)` zTOWv!Roi|M73Ho5Hd*_+#qpOW=@R04*{9{%q>M0Mn_HzGJF`8y(j`H^TiX>r1+3uF zz`kI+3s{!gotJVV<=`HSman!N9CPYE5o}2KMMzakZkbi)MeB%y^lwTrz_7Kn*a9p; z;D?Bj!9+}=Nx-|xDLNd)JklUK+ZfwN*&&5~Th;f9GKSk@H4y&7ih-5Lo!IuU`rG=E zbWfw4!F|I^)MU#UBGDv<`Ft%V4}J@RF@bH$Ce{0gC|Ga`un3;bDg6)aYtAyyJX#s! znEp%$r#OB{;%s%sV>nlPgV_Z=jwJ59 zACc`Jrn(e2q{zX$67< zFe<7&|E3=-0VXnvVP534phY=;vL(A2@tqfZ&P{M(d`0lg z`vERWLu2E4kapo8z+F1}zR!hcpr_oUQ`8SOtmV`69)({gCP$Gu`idUXl9PivB4Oob z78V34c<$UpY{x5O;%_+svUbZ_&VahG$298HzHdWAgv1Iwiew{EgpjG1I(F>hvuz6M zJ?Yny205am&VyjJhC(d=jf3AWaTjED8`aoMseU@g4P$S)0Cso(C6CfmJ<1XM2R#%M zT%Rb(v_6cY@6D)civ<8;6x|4?KE1pd(p)mDTmOo62&o(-$USEb0l4>%;M&Kt3%mT$ zwjhlyVq)>7X?193#$V!nE`jw7*ZN$H!Q>AfTty)~xBx;@!lCTD43igLU<)ds2!_$f zFBL9^-2I%j6G3Dg05g$h6sQ2?ff_AMxEa7OP&&K2!Gv{-UW#B2VVd5<8OBpg_1kFM zL{CpoA*OoE`vYN7Lf0+_j9buHf9Q{nvTT2u33Ce+K9~<{0}aK%v1oZ9fGc)gOY7(aX}U*>Gk2Z)U^Bf1eE#)s^@8L9ieW;q_`{sFA!c zl_`Q5aUQSe=-|&F*ylqCf_03&GlP*9QWCyKQ4bX7r=D$G5#?B*2y1@Vv414N z{s%%($WBRqeica_vAyV&TtE_GyhbS8R;kD?F$)y zSy@>^7SUqqOzPbB60UNs`*2({ZtP3JHG!FNkz>gKomM7B%(7zV7R>HUPl6v8SY}*p zchoLz1{{Yp5hiXV{sn6>K29S%wxADUHCGuedpD7Yi?(qdsPOPe<4E``V3iDc>v@J# zj`Il#c>oLvZUjkv!=mZcm4#&&=X=E7CI%hz3|+C;gD(beAm%!I+X5PL9l)lID&>hf z(~CNj{jslmVA&#?kGM5HC>L}^Z}Lk=mb<8IWIH549Z32FVY#sjcCd@dts5Nck8T={ zCVisTG2BgR&WKq&|1vV639^>75%y^Xqs2nC2j@Wr;Az%*o_HhjTTNia8KsBe*pJ|l z!5m|P9|Ic|@ss<|E^QP4$n7}bj3@#Ye?zFy7wdYKAJaK29qausA(e^PZ5#}qL~Odd z^CE~woS!6xoO$rNwZ7dtZ|fHTC}lFBl(&8)z&0uWRRYXadz)+b_^bub=;tD81z2KSwP+xr zkbxH)>4f1iwpxZ3$B#pQU^ynba_L)iRC8+B*x0CcJTnfv4wKBU&==?wp*&;!R~H(g z=r}@(MZD_>*3>>?X+bl)O=L4@+@mi+m{pH`eW;=f5l4cA0eV~{F2XlRx$S%6$hlZ? zc1?u15G5APl1tIcW`g2l*ml(kGtJf_<6qXx$gq2=JEN5IB20mXM~+ZPD!-5)S}z{s zoCR{B-rCaDoiNpsBQ-Ah<>m=HrB7$0J!1xFjqfJRCVLw0ENtx<8sPGT$aDcN>Xn{n zHYX~|bZtnhB*As)SmTmtBY2xoWv264J1LnIeCAf-8!|4wBo{(Wm8);#x9}D>&v47AP}}rJa^;%r-w96Uclm7y^6ED{-0<$b6LINm@mdwO z>F-X!n~See+(~lwIKy!2Z<2^m50+EhoeAg!D1^vMgBJ}FMTFiBtb_f!$&*ZsR^z+6 ztjJ|vtC_9^rH?pwo?dX|zA3tUN!8YzTCOrjo`Z!;7DdVoyZ8@%;tJiAs%~d48Ymir zqXDC}F8!6V%Dq}V6}DFuHliA(ym?$#WV;zmA%?c$LwPiA@X+#ClaB=%b>0?+2$xfE z>t40vUH`aZMKqhXr{Coq;s$Td7nu5{GX3n_z4^-Pn&FVAk!#p1HJ}oDTD;0}1GzP$ zY1y~uFk$k5;s81h>ee!x2jbPE`2w6sD!t`9up7nLP80|ELy%mdAw>p{fNCT)c5xLf zN*cCJ`hL0CIL0Zj%{~vLm-`;ijK8cG5D>FH|;qAs(xTJ$tgw6~yny55kFLApP zBNuLY%kS53+~BY7eBTU;n_Fk+=0qac#5{+4eI5`oWAFS(sZKY?`}S}jrk~GS7OQ&6 z)WpFI^n2}MW7!qTNpHRGK;YRkN-#D{OXDYI`k zxdRqmrKaHNNErWc`|{YX4>QXL29Hv;<`eO3n~`1U=4vMyg(VE-IzRZA!}hcMOTgZ+ z+dZVZe>pVu!Lbj6V=NkXlJy^*+95prG4J7TQLy1J8mskTz!9MKw}sVy)YQ?Lmr!%F zmY02O;T*4~(@cy~ys#*VE;l>)vSx#<>xd$TH;@_FIjHf zyvZln(gqKeeZ9-y1;5Ol|8?-IJ2w7B=rS$MsOsYUScpjXR}|)%`DW}1yL*P5ZZ*D; zGDbEwT-4=;_sy@R)asU znF|K?>K%V8Y;Ehhoken9DZ?9nm-C|ijN#h9oAX+!{ETnu%s^D>3lD^9kJ|G#5ul8s z`f_EeHPkJzkh#z=0M*u8fQxHaW8>>E!ZEf72hgqa@hZ&BF3;w($T=q$B<-0P%?wSl zG4TO8&x2zjIb5qvtZ&*bb5_!M1i)KGQc(D|AV}u&b^yW1XDJJR_jd%MP*8|70j$%V zw>`e1Hp44X1(ORh!ndw+#Pq*^9#3>n$-hCoRQ)6I(m<7M4E3|TE%BV7ioq{N8{Hc# z7!=wxo{PCJ3|DTQEFm*IPy3cdGXPJ#%;HWztbB&p)!BH5fTFWb<@#o?cWF8aSys*a z+G-l;UTF#iqO+M~&0s4W(W{w5aY2!}_ax?HcoNb~g9M7g05|*{phRea!rgM~_}L$3 zn&%*dlcOQfiYr$L8C;R~S@97kc}3tqurs-Lo$J02&D6xiqMhMa>KJg=g44_~5WoJTipN@*-@p z&v$mH>NfYg1a`~yG$z?KU3<8KYz>$z1l4n@xzLMp&Czv2G0sPR)@NMp69R?8oE8MMXpyd!hX+MT7 zgkXZ)8TJS0Yi1qOvUb85NJDCv1)lcezNS6A!Pg7U%?zhS2g#_}^IHKKGhURL!5o!V zGrED)3$(!R5SKHUB~JzKgrDyHC3q5eFM&Hr!7h-GOm$A2kUJBecy~64Y!rVo$Z{SM z9z<8<`QDyuAOz;a+a-l^N3P3hEh@(O1VAjdsn73BRN*<<*cZx@B=u;+asnnBYr80 za{(Q?-XeZq=Q8ZPkIdr@u}#Kg9KU~+rm0qVhO3;ShZTI**9WH2DA9`Xk@ro;jBdSr z^?TPLoP<;Db~B0{JjmFgyR+#sOcZdj&oLfe=eOxq5fkjys?tQ*`$)zaCivo- zNbJh-IgaFhbR=1Fz*hYov@#MBc)s|mz-|NrTw2|l!KSB2PVGyZj?R+yXWM=mAEA6B zHdHPMVzvO5?wz4aVus{(+if~*u>&V}j7%{+zz9KEDznFyihkC0gJYnKhH5NU`Dq5I ztF2`jT!p`b;bUkFb(oN|=TKkV#7fA9V#qH!_)^5}xGl?uXQ1lfdNfQzC_Gtx=gj4UuAAU>=|_!O%a_7sM)1y{ z3I!T$ZnDhcpNc|?nJ7Q{Ir~yx*ra7zRv-QU$(TPxGyKp09vRc@)75+$ST%GM zHlLOP15B{>&v5sPZJeRL?H#%UBTR(4=0u74kq4+O#Fj~ne(rBm?nt`k3kr{4ACFx zrI-9h!|Wb~m=D7)L$3~yhnnr-E|?O_W-z1rd@tf;)QX&Hofs@95M9hpo|KRN?nzsDsqhMne=zv7A$8rz2C44|F?LUx+bI;2*W-2o& z2MI!V2SAdDFyb8UPdP9!uBxw38XI~9#5n~FSUezdfqmmlg9{%Jwv7>5iLlcFzVKkS zFUP{7kkoXJNsWx4_c0oC$!jW6NSk^mMlKAFlY{pp9pc>fbqtNve3q=nd+Q7hd~mt{2PDRnTWR)*O+;oA9Gp%+9Bk#KDJCk7?%iK+qF@&Ai@e^u;_PZ^pgmKF7lzdQ*kx?&I(tuUTrJ1EW+Pc%C12TL zmqq}U;FkxtPg$RA-2a#DdN+p}93Vd9O2>jPho?8piHr;9-2=`a9~a!rPB;M*)?{0q z1txy@OEa+V64t{`=W)o4MmUxNID0~=5fC>A5AsdHoeN+miTXNCjdci@?9%c?oaHlRe=eM5CBcOBbLKe90tEQ${zucqLgaIVe17 znbN=M3tkGDmElJmp6uS=-5IR%$tgAK{qUz4S`QecEiDC!ycm*A4*IAw+LNMf0Tw8% zthn110^Z=v2#Dp|oU>Mqb|-Jl!Yetb8%DuNB2ZS7eI$^EMuffDLk{@8Nxz=_DiP$Q z9R^K`%~ZC!KfN{T3aS-8hkKUqscR)pQ7|@Rl-Y~pCq7%$?EOdl%Deut??h&eUzSFu z`vX-s2HjnfTsH8Yw5u45oulV;<6cNN-{7;c#?U?LPQg%fNsR z3~}u3iF1g^fa1bF|6QGO@Bd3W<$}M{DR)vU+(Qf}%!R|s;rphzk>C=Wz>H$q=RL%> z88Nnl9+9@{K~OAPp;+Gip;&hRC9^V1d)wijlV|hB%uH^Ara;QD#WOJ{VgLyWdxW{s z1?ON_AYdU>KN84@e!>~}^}PcQBb!b(5s<~)%sc|epU(5DGP{U~DsFkV&U5tWJbr(+ zd3jHlAjYiM_#JSu*e3bnXbUwHYA6Y_!p`Jb1HKEoIX67SrUm-|RDxm;#uYzk1=S0` zf??27I=W52zV2soaE9I1d;D`>AL~T$n~q>5yG+3MIduKIw09-wT z8F+LOQbASoky(=-l<{ihdpQkwY>sX{C_OffNDE=29>FVni^w){v$DEsmAtjR8u?IR zXs#{dZU79(VvB;FR3SeQmv3P#du>7y+ZVKYsH$h=&tqOwqu|sDw|2Gkp>CnNDaC=wU{I{KZ?FM6{9M(pK=wkT1 zND(Qv^aP010-86MK|%wf-Mj`C@9oKjnAZ9}9IPW9S|GJrKt`FbQ?vE?NWxa{HUKXS zbwVUl!{1DS%au&Gds`}BQ*6Qr<|7MY??~6IU*5~19BWXEbH$_k^}y@+t%x<>2?*UM zs?dKUEZ3&>`>@<{wq3_zBaDAM2}9z6UC&$l&0MZ?8l;3PGTWz59_2!Bucp7UzW%8|00C7F zozmrC@El8}_r1y1GAm>{Ew1~Y>6jK+^JJO+He0714gak)$=8yH#cYiQ4m9BC<+rqa zVJC#c!&I!XUUjpnJ0Rx+wXzz=&>Br4l=dmO zt0k0C9m(4bon{9bf=NeM=cBA9N0kU$a1F;u4KWr-lc0EsVE?y)ekzXzQ9*_i5@2GE zFG~dzjZ~vYM)OBomh|gNFM9yM=*iUAb7sSg1lh{OVRryo<|8<3VHl>-xl`;N0rO$l zxMQ|O4sNT<*w_ir*ToJKmNn{{Dw-FTSw1{h`!c2J-picunC7x8QmWaD$OtyW_$p@Z zK2FLsRGsb91T?kItz3FJ_zETOaJy=-P675rm;m5JXr1sfmvzi@UYRW#Py#|{`uylF z?QD4}ZvHR({MzU{)Y^$1Y73RRm}37WyF>2Lkoa%7#{ZO{;Amx{LbW zZ0^$-eU|<=5xgqKbO5wTg#saNU^V~^i|U|Dnv~gs#S+a5;NJs)Qgj2G{tt>}@cIqv zRyjTJ3I#b0)!`tN1d9d98P(}>rPioPy>H3`0nk8C2rKVeX{Q5{qi6o}4!=}P``vyJ zEH&4D{_I@{pCV%Xn@^xYHgu22wI-+I*ujzN15$_8$>OOy*iCHD%kb7|(2lzIa$%v~--0voa3Dh60z* zRjnSoC8pmCD?UeLqtw|i;Z*8n(PKA@4XZY{qD7&0#}gL7;7R#fP+tx(I*bWouSvvP zH8Z?x03m3as?3twnr-oh#(N*c`YGL!X6+KY_6llL`mDM}0-H}@-x(eQznx)sTUq61`?iV2Wpf%!DPMT!Q*vBSH6%VNm zj{D zov zlMexk>z*=eNS;S8T?aYJtbA`h@Y?z&weBH6C%l~;G2HK#KMfoP zse5~R1H$^BRE?2=!gxtb}eo+5}mSP1kcSd8SV_p-P@^4n!ejy=4XMQ z-fsnd(&p#j0bD+uXy>Grr$oQcUUJ=c6cMg)(BgYXRsELBe-;%-$QUe_nRr)8Cb0Mm zc7ZD$EAku%I?YEC+hT5hgtcvcw8dS6|C~wr(ha53f5D_Ybnk@=-CCHgx z|KxZP7i|%VWOnnQCxS31k#OT|CLahKxtNCL!Y)K=CrQVmb;JO$l}BMB@qXApF&RAt zzv>jmCj=Kpsy0Y-3;q-QWHPUYpOW)O-&GOKMD&gYYf4@kr`ms8oPA^2c5n{CQP?2*OUge3DWkd61)6HLt9_A{ z^g+Ete^FjIV1THUp#}Fwjp|t3F-Ur|88`N(K?O5e981PpRSd|jcuPoqv8$m4Y{v`?hDg+s9~1D9)Fwdlz;9kUQF{0(v z8~?1_mv&8oe{+D-Y+UH_3V3LcD&orz(>)n*1$X*)wB72^@)u2n#|=vIDnFb=V)v87 zVV`k{FLr+YKQzukZf*6VL~W@K{W46Y6FO!0yY9~{eIfa7Y{jD5_Pha}hA+qon2rAt zMaloAJ_Bn8GC#}NT94~$U#^>hT&eVj9oV!Bv*kun?fa&c>kt7-c1Mk@BcFP`Oms{k zwmO?t#$12!}b@qedW@M?%@MNZ#r<8d-d*Jq9n`P31=dHm$DpGKKk=C*N)w{p)?KXDZmxj=p_7e z$}%o-(cwVOZ!{H!z#Lq#5K zck$f`^{&#T=JR$)to56?*sr=J==ruA4G=VVuf+!F{1{K0?UqeaY?-~mKSPp~6Xi#a zm2d);j*C_qg9^WRrc2mmB9-d+EJV3I{6#NVh{3y2&NI^vvzkrbOZcYh+jLX^kWE!R z!%0E2VL;MGSaM?JJ9!cJa+5+laIA6Kjb-(LKIJ{#(cVt%K^Si??`dna=)GwhvaCIP z$?SKAGt7Hh2u&HN;UM)E2ds`i$Smtr#2tYjie17pJgNF$id1@zx9ZLY7Fn!}SwW-} zKwn-LhjT>+Lb${eOoWkZXMUHKjJth%2OIZ~LL$OZR1DaSfI8ebwNHOQBsq@+RtTya zDap~}Q%)HzXzuesk{jecB$-WAXuw?7U< zpE{NUlJv@IDE~pb3}yLK)bdIR-s<1|8G3RQy?wv?Q?C2@k8ObmeGtwR?ZJnGwF_S` zSKNu=Q+rE_9B2QCGT%d7(*;EWEwf@Uc*N`*2`LJ=vsYk(CR29$6O$@Y z6Yx*0g?F8?1I=KvB7$sf&B~aHld5T^Xxe?dcDYk|aTCqfrOxV?p0?Xgw=JZA4NL#m zu#Ms)JQ$I&yMy zjSx90Byt8W7{a9Z0{e{fJ;EZ#vQ1di z-)*jtA1JscqW@?33P=`~wk?D)Et`y-cTV z4SIOAwSzi&Um(2MM&ky4Je}v6z^!6pEojA+IQK1u^Dsczn^>zFA3(g}aIA5q#(#-n zzt2{kfCxTT0YH?WV$WQKd_Yr^!B0NdkG?)rUnn}7xlvRI28gt88W8Y~zu^iTS2 z3}q%`Eb^jS)F#wsZ_MD`kXX0OE)p?uaq?{QDFjf+0JBgp6dUWhxar$(6|l z8!4OwCG(=to`$ofooH1!Iov5c0ky$yPpD#@U=tbl-il;M8v`?eIuW#AusgMz{hUP{`9POvpR2PH7 z`tj5em|_jf1AnXJduID-4j8$AEmtl``jcE)le^6^ZSs$ee6T4jpB@==B4_!(>g3xF zm7hUMdbl)n7Hg|XXBR0q<4thmZaA7W9DN2zL;O3(S~?@_vAfOCSbL=K@-pFGHl!=q z%-9Y;=;f#){q+G)OgSQr>xCjG-qR^P2(RwQg9QS4&pW(_a8)WODC{|OXbDKnD0yw3 z{BoAFpvlQoz11GWfc|Natg-Wh2Cx_fKGuz58icEeT@v=qATpGM@2YWF7pxLLN%;WO zOCdgqEQ7%qkTKZMDYgXjJrO!kj_{cDD1$}DaJ*S;L&BP`c2K%&&Z0g@y+^5A3*mqHZy z`7isd^8WW5_paVO+;$Y~N`{jmpZm*367nqr38Dl|X*hk*u}i1;&G=`cX=@4SymNTx zkLWuaLHx@aUEC)Lc-9VRn8flgDY@3>>9hce z9pfD%@|vab?vJzOr?qSQH-clf3lK|AD-=thmJG61oRUxHM5KiET+A+oC-^sQ+KK5N zDN^;<%bnc2e}7DL^v(dWy*TB=&dZSA4D(1UfD`*_D*;&4kFUpdV^E79_vWtt5(){P zzOMQeaFlzXT$X>kuHbK0Md)EO#Ybi`v59D5vB3=!osKfRTL3^pJB^+a02v6~p-3Q) z0Yq_Uv;c(w#fE^lY=fPm<+|`Sq=;ehKnP!QU1e+pwTnvMeX`)MxSD6n2d2|UrmZ#g z9U@1kcdt?5yB*9Tf0b#u=6SPw)M!TAjSH$7ZwJ}Bd-Yl}n}*zpDRg>3e`>$2m`Hz( zdQsm}$&HgoLo^?Jxszv6;`u(0?a7(2#a|XzUycpvq^vFy<+F}mcz@5U<06xjg^x?l zS-o#5iQ6n|P+8I{&09hLC9i{JxHZ3T8;{tVj)}#Lz6jmUM-!Hkh6d}?6%c0n#in*o)u} z#n1Joo<>>|&Yk6GGrF5MRITFuz!6a~dPD$Lu2-z)|mHs+|iIiz)Mbfj|8JjL*XlMXlhGa(l ztnYnDH2@FyZGo!{X5{^t_q5_a*a?alX_2ziyd;}Tx6^d`;N}%o`>>*V=ib)6cjSEC zg!holBaF8S<`2cziJGrDvAQxK{|l2Z?bD}EmDJVWG}aOx0O@ACaf2qZvVbsP(VUKV z7pVfFFQpTSKug9bU_znbZ)L*uI0r`mmJ=QaNra|oNsulP%4`(p^9zq%jJ6W zaoM6pny*(&Bk_9SI7k;D_M*T+8sD+1y84$7^*Nvkz~{_Lwo?G*%x0W(FStS*B}n|R zSR5Gw?pE9=ZAiz`onde9vq!C! zyZJCeJs8~;O<^W)4Q)}&us7cm4 zF0X9D;S$D(c%P~VpBmQ~VNyJQ-j!GfP&_V5?^NvD(ZbS#(GnMF%aMBY*X6nAc=JFu*&WObDw`M@8idzZhQF0yRXu7nKYvk2SD#n># z5WXc-242rq$?!p!B-md>tpo*B#Xqel`?M23&TGzi8O>4e0@m+>nq z>OH-@7>74h>m&^XC}5sLTC7n!82wqPRHgvkcB-G@A72KY)nE=sHu}m*a;FzEI=+1& zka+a=b5**lG#tws(vfb%UyGJP&T0KO^IvYVOa#~O4hqqCeS>!!cL>dcE~Q)bQEzxj z#f7)#(SZl2btig2mg5l;i8ZT#W~9)rj~0k_N+H>k*u9x^HCM)k#%eQe zbAc^mzK3Yk01iyj{Q&$~8Q5iBd; zz=xYIufXMa;Kn4`b7F@Wm%NhFLL!Hc=<87O_$OKO#f`Tg z^3NP4q{df%Ud@TIG0r+Pk%`JPMleP-WLg2zmbJFN|7g$_tF`wpS(fj$zPn)GCtU7f zt9dZgK%Q;o67Dg1<#G(W4&@a9(>-(l@LJd*hXMWi2e>sgxtIPvMyB( zH$2`&PPJ9KtlXV*hk#<;I_B2x5xo4uy?EE{EcT%{K}&I+5A2_w|A1ZLbolwJ!NC_X z`)9>>Aqx*~VQlu@=%q(x^>s#XxQ?+F*Lt|0qydM7c##QM$NJ-0AgsF$A9qTr=S_-R zyxIIjgsQT5^>cA^7EnQXtE;O;r*Alszt|*wy|_3l5J0fXu83)cAQC4?H5~W`Ukjla<^JsH%ZLLDWQFf-TB@xsu3PZf;E@M8+LH|&iPD=8#2W?M#xAF4UBn@fK9Za2g zXso_6B(-Zb<7dnpL9sx0=)&atvQy6TTsnJ8lW&x#vc%5+35_7RVs=>)6Ktg3O;&r#o+3GxQYp3<3 zvsY#~^yrk~{R2hK>%Q>r;#_^GEh@w8S zr0+X@`j;m^-j-6@1vyS|{y_<1Ueb`2k#R-h)i4>)m0)RYv4*`YC-pZj#z7#kwQAVZ+BKd?-68_;+TH9Igh=pxTM6Vq?-3u6^GhZ%S_#^ zGzJ5=!ox-Pj$zN2_hvk>a-J~DuIwtgsXS8J9a=(VK$Pg~*Hajs1A*l;6)?>m&0si>$O^|4g*x=22&YnRs%F3 zhI+0D_t>7phw1GFjSGMUCqDP9*RNm3V`;yh8#biFj~8yfDRy!-j+Vujyhcauq5Amv z1j1F|J%AzV0?D(}>{Q!@FkU<~l)8Z*g9U@H`wFXLgSN|#b9h(*_rF6|cQs6@gkB1| z_I%JwTL`^0EUCv=K}MjXZp02B4##!mWGyZ4 zj)q5sg(Z(VrP9UE3lyEZhyoYr=DFMOLA?cW_!IA`(s4LmGY7 z&)YwJG9=5G@e{=;`V0-TA$En^>|2!d&DM6{gJN8>CRyo5LVG45UH>o_GLgDol!bo>=*=rAS~>A_rNv5fX2*)R%ahSM%sz3st^ zN*qLr*u0rA&PK}vJPYVisxy6(giSTM>9%u1^up)AAcbce0!A1aG+Jgw2Tdk|ori-Y z=4o0fX{53VpJMrvyQg8GAB9+%a;IT7UlGO1grWh0FFZ*IVz_8C5kz!s1(q z6W($MBwtGoPY)k%CF<$}UBcbRLon(jNf$Dey5N33k5o8_tArV9_fP{{eNu~3H<*lB z*9_|vVB3TdiFTYuawBOlnZ;w|b@&@ajp_N1*>4R>&~y=g(f^_DO~7hw*Z$$vY>0{| zp(v>g4Jr+q(1enN617rjLP9hziJ~OQV{DRCh9pW!qe?QAW<)bhn&)Ay_5H5m+4g?- zJN&=*`@ZA&9M9f*9$Bq*-`9Oz=Xw67)vE{FyYU{tbC7yFwgpQ>P;Pk}47ot9cLsMI z`<_QirQP)X%%X9vGE@Pe_+X?}>Et6#EA9XFpf=_Bu)z#{o9EV(Fe0?G>zQ#P=o*Yt zaoG|}`Y@`8@8>m`OT%;o|6wWn1N%yG3Ch3ci-pSeb@{X2Tvg+!V{@G1QR;w1bXm@epZ_%JBeDUoF>BPegH`aHDxK2)o6*Nd_S z0f#ZPVt>IDAWW5J!2Z@OG)0CmYdfwP+p|zy@=@NY&pKVs%*^^9z^{mTDBd^vaNHlQW5*(3uS=I z{QJu!B*ft175-jp3ix7SW1sRoT?4BN{_Il_4#AxfEIB=IZ^n)3PcB|>s^~D*)WoG+ z4M*MHm$2%E*%aD#VzZx6eB`?cmhxbW$AIR^ROQAr!eM6mGvRaY*77!2F150YyQ!xewv)m=0FJi&0} z-u}z3u~e)nslL8mGk$JcR&(=yq9w)T0CENj1>!agg&N_Ph)#BwDT~@535mOBE^Cg)g5*Ty=jZ2GCs8Q4OQ5%&r6Now_|W*4l5y-d zBs?a>)Wn31a?8zU2{UpP<}o;QD|B9;#nAx@{f2OE3Nb8Hy&9N&MP6PqSViv0*iQ&lx*hlCKz_xC7qw%Q;u>wffTRenLi z1+7UrAii1(YRuZ(hQ>S;QtM!Bw^UqQhduqg@HI>!Y(PH824-v<TVa-lcc+L#r}8t4lJWS`kGM_vH=L4$adn>jgdkmf4pwk8bTC|tu66APEA?W# zb>6h-;;;-#!!-@%cQ)fdyuvAg(0FXZa0z{*?t6?84xxN9r;RxGQ!~ zeQa3qSqNo~f9npIX$k}lyFAHZf~zY=!u8YL4;(zWq8)*^_-t;KEAb~D9xo+Rk!ZF% zVf<9(qvX z>kReuG5taVg&Sa`_wKH)uEG%Ev99;Q6m#Tv&r|vZ;2A1#pR^|dVFW+vTqJ%X7~~LOIFQ%SH8G=a zM*WMU>x7VJ=k?P+M+H+dzzP?!{4&Pc9|B(G%MBnyqrKXe7&)f5rxAd8I=SxG2EfR-y6%Um5K-h{ z_|v{BXYP$NEk{nIU>}QgJhX{Alp?Jh4rl=%qkX?E}0KAt5LP549&}iEbe91RyPNzgShZ3f}Al z;t_lA-W|Be_k=~d@Bc7W zz^%Nr(ZW^|$$tr3S^Ykv9u!C+zN6T764RY~r3}x^w4JNlE=hWCV@<^&+X#R?SE7Xrlkwk&wWys10FA!UTcW)py zDj~tpn3@Rh+-1v_owq@*FG=izhd#KI928=GQ2}!TGSbO@iFVV)r2)%c*=sDh`h~cJ zL}0ceKa_@GJCIu2jwlVmM_u_vZ259dwA*YH4B*MW?95G2HAhF27#N{y4>TMtG0MvJ z$x=f7E~=_3guM#mlU=rxXI8100}9#GE#t^G6KGrX`JQibR@F8cyMtg&V88^t2R3^e z*-wJFrWzAS{}R$<#qT8|>#P0$;t_PsP-6v1B8PAW%_JBVSEdPof zX^43zi~|ga_+6SkJDu=mNyEy2fG)mb#SF{>EXOV2vRLchv07*RVzN5hff)Mfea{1L z6I?^98O&VB@u)ps z`1-Xqh#tVF+6^N$EP7v}{KY^Xn}t8RoKZDh+FSIvkeP!i3}Q4VcRPE#%Qh#bj;<~% z1$?>aVxcQIpNXh9fSK~?lUZc<%BIp^(;XZs6Gf2|7gTGy{rcS#QtJk*?&|sDNj5Ox z#&e?cgo6T!ZxRube>fNdQ&}Pz#I~^FE%Ms%I|;uF=sR}2rC5hljer~pSWm95S>0QQ z6j2<0JGVq)Kt;ZCzPNASK0tM*7oYEt7YYifgDwyI2ay+{H_0QU6TP?Shp^v(dV9~* z<8?tnLy%JKwx1>|c_lR?F|V0J;S~~EQP!OMwjqMLXV2{$3=82UJ40jrt{?oigw)5; z7lLvh?^|>G!^Sb|zt9omSw#mU09X%B+l2dy=*t*s)yKrcJa9Xj4P^UC;h1#>YMpc3 z#~|R5!|v20i&04tNDBR@(4N#I^U9qPaPXp;3+U@3Y@0wjIjE-G4{Bt`QnANvzgp=C@*b%-(&8NygnEf7ba6l2ec@167xKJiTR2=m2)uSX29#5R^0c=3 zha?AnA^7FFIaGnc&?{H+lTeCU;c_&p?4nYG!6w;iq4TkZ$GttMS$VLmoMz7YnI}(u zE9w*&cm$L_$GRaFD1=<=Y;$HpG)&tl6IdiWiU8|%-V@6BSOszqd|4P*LPLECEb{c} zoWyK~YSvi$N^H=L1a=nKIW50q$%!F&pYt8AtgU_gX5jN9=u2=wgHGIBQz zeV!ZJyQr;WN`Yu&-@Z8|IavWNeCG`{9z24gj?`jzU9%S#b)-aii)>OC+Jm7)NLUz5 zQx~hd^Ajfq85u4$mSCtNz)a!5vra&-Q_TnvmpdS>CU&`5EE`9#v8AQ7rP8ZcuQHAp zTt<<3_1d+@Hz8uDTbz`BiSjyn{LY?@cbFCV;o#i$iYQPqWFb^3vU}Lu^SPO0Cnc}) z9L5S@jplToMBJe(2NSG1=-6b|ZUSFR)FNCaB-Iu{Y<4C1wj?8audNpU^te*~!@2UM=b)Q86)8A_doFArI*G zY*~lxcc;K&rI?oq4Wq1M;PE%23`cRhtk$#Nuv8cY6RGMevl7ulqnWjK5VcQ#f^$Sm zF(}lN2EhsOi5p)jC6ycOJs@UWT>Y)?U!k>ASH|~PTSqslefME$d-YiJi#+c{DbJNV z$$|5ww6;^J-N&`R-%@nUZ#q@$rf2Z15@#4tltigx%O(fE1a;9Im)S;-YEj0~{1Gk0 z4NJpYx_y9Ew_p9M+=RR}kiq!(0Nx@ez>_$yUA}JMIc3+6- zhX)n!;pdPg03~~WTZyNKy{f{*ateq0s4f(e1M5{ctUYpLIJ$n?Z-H7d~>U6>NzYm?i zp`dGkg7Elac-nw^P@oOT&;iF71P-c<2-)J#Bccng8Tt_%NPb~qi(H(S^5WKxJ0U`| zF{!HmfTX7(O)^u@C|q@CxT5r5lVRH0;j6-S1T72Iwaf7TJjs-pz$Edewvii7+veA zWHxuYk(eG~wQ?TS@U2?&x1F&KU+v0j(sR)dR?yl&R$aTVdyl0japb9GgEP+(;k`3> z;!14JVuzPgx8rVb?&GDHWthPLwEpI;WK zF_oo+Q_AaWQtRvbB;E8 zon}0VG+joaPV%0?O5zjnu1?q!CmkCIAiRe5?BEAhwY$$^qV9Nx>L#7@^AiQRqn}nZ z8E<{1tN-?SzrJ6`c~W6@vYWX{i~S34db(;+pn{IDCwu$%+}ZXDpX`JrKQ;w^IJgmI zIC|pCFd@|~1YEGi;zWLP=L^`0z~RR2a2daUAK#ubw@vFMLW|fT)Q8w1F~nlK6%>aP znEXo!zl&S{{PUsc#1z)3$sJJv!(m1C?NRUGXxDb45p%NitT)~OwN3}$S3UoR%q7y; z+@1_Nw>Iq4R3k_v5jq-}lF=dXHq_QDy0)3F17Q+Dhq=%1Tp7!=Qy4qI-kPVnl^Ro; z0cRvpJIT73(}e(6uuf$k8Ef?MO~0ZQDZ*E371O<-?7S5W%#ai8pSM)kEQkYs{_}$G zb<1HO3Hiz6X_q!BE%QYA3!+O2LEteirJP1igL%xB?%clv*?=T!PpP`}7PW29F~%AbchRK;n1eY5$gH>5zHo}I=Md@8uXP?VRC`=@f!dF zTpuZxseY^|@0@iIWmuyJaUTK+EeK>zENvUKDD916%biMaLb?XM33V#lai!0F>C&Z* zi?BiHq~icJS+v=)_Z3geDZo8B{kvDHs4V!M_t(tkYoURS3?tJE=Q?o(TSrOdS$O4IPjo$az<>Z~fuqv85UsmS10t|33(?J-PE6UK4($YI5aY z(Y43dFz!LJ>w{3eziPd!_XICrK5Bcgjjk_^ccO;)zaT%5a&zRZcdo1#=XqOn?(A7g zL{5-#9wo_fUeepX&;HvKT(y0(r^gAB7zrhtQF`%Mfp8Flkq zn9W8Eb_%hxY=HUCe3R?>g@ra~yc)N3z#1ZdaahlzmZwl#8boR=CUn*$@^!fCJknc1 zIS~SAPa?wgmE&PPdm1<$D#V?^jWF@faLlBXjICy@p)ov@gllff+h$yX=+Fady}1Bj zgeX?IXE<)GIWU7TH79ySm~xghx{F-;U5Fp>=-x8P_k<%Mssg_y6v8!g;Xi=nVw7eIKbQ0;jcUV0I<<>yM#mqJ$TYLh7~@vq9KqSg+P;V^YV) z%?~7(`E8d0cLhDq4ON#1Bh05}t44BpyicQ4Z-inrx_DcxolpQ73t}-4Qa`gid*WSo zJ_IW)CH+6?!*!+=GY2}NB7PxDQ7+!wU+OCntLu6R&Cv_d5!u|Sv;4=?SEd47BnG*z z?f2uz>cDrz2fCK_e|Z>pX>uLE_CxdyB|_0u1|eja$M4v|AK~6EmGg|VLCYlPRlgf1 zc`CU1z94Ah$Mazbk6X}4VgSmUu&Q?K+7;lAGfVcS0otf}O5ys2UO0y%4-DMGM^jD& zT>2IA(CS309vVl|nu4Haa7$tO!GYutcC-S2c(j zOoBq|{tID{s%G*5F|E`n8|F=|Nr)E^7QTee6+NmN*eo{`9p}dy-d>*NL`0;J!Utrv zwYKthwj6tLw7XV43^aUX91~XuG{TrwfWnOrCDuOLGy(GL!IFreCr@O1UsQiT=9bw# z`N);#U%qmM)wPkV7g74;#QV+L5Q!c*sNUOJh1jemu_Jg$b5ZqF=(Km4G@#k3D2t`)4U8|^oa%JB@i2QE$iZZ$Yr{wa*blqB?(pxwO4}%rpG@b*_h%4@L}DE#3$bR>>MM0ybfbBA5LCZ6NfqAYwMS>5tHo zq6`iBaq=Y`Oi7A0>NIzb^oj59Z_2~l3IAA%6;9my2k!Z`^YeeeJ%75y*&&IuWab5w zcNoKK181zR>%v}C-I3IKxCGS5hLPT;fDCMqO^M90#L0Ze-K&WQDf#4UNxtLs{hGjN zU<@3cnCN77s&hWPZ81o&EJ*~F=EJ&k;=h_WDbwxS8wVGWNHfv=O@PD8Jn>S}-T!|{Tc*9|#5ES>X)5jyM>0|1N= z!_r+I*0EYJqRHJXk7V%U6u}QzSSI;YtGuS$y`wb0{M)x6SkwaiS}(_1_Wc;1%-oe9 z70rQZl31BSs4d|+b^&7)skfU6Idb>zHW$k>{`QT;z8xu2JlMc3JFdOhEB!Uo!!(4m zBncS-JZk>c` zH*ej#YXXUsK9?`^V#!|^jO8@HeX$E-E%2PkL3H$}EPl4cmtaSi$z3c2l{h!BSJ_WG zK1ElL>^4a+203S+(b6i|wS3CJ-dW)H|s^vSY1Acz$@ zYzIY$*hV7<_SO<0v?E0JON%Y|qvZ4iumF$MpMU86(cu7`} zLIH~#ZgK@AcIDf*gvYF|?W@6@0`&$bBPh=`LD0z8HwA0z+SuGppCNmO&S$uc)Gs(f zI4PfA9G4(~xOAWX7m(M{_f{4WYz}otWf9Le?N0wBU3W|H>GxrD*na36K|P_)UuE`$ zf;x`!5fln|_0}W264lIh(_V1ydWgs#2Pevh^8o=HQ0^1hL~;@b_15&)5{ehm9iM1U zTx{NH6r*Qs9HLqj_xt$zEldRV%0oe5bRN83kos2!%r*r1$Uru=_4ERB=2Ad%Un(cZ zhm`bP6+Ur9Sa=ymPeAGUT3Lx_9liox0`pt9%JOHLqM2}*3PxEYCLy5*d>O1NRtiRN z5<5=AntwF`If2Xs0hDz)nPXVtEw~zN*7aJtT<8xnzyZU!E9Z*%7VW5+6jI&d0-$wS z*>3ka$%YHV+q1;2o=oCV!%&t0m$f*tclr*^k# zT=xfa8Ug=LZ1$eA~=O?=gFqojLLs z_X=$~fq6QSfH`H2E=PIo4>skcj+AehS2Iq)5Q7ZXK_lIG%lnF*GIG(xH9E$=D9`$3 zrpmzzNSZKTK;9*G_RlC3Jayo+x;+HBy&jIcAaC`bYIvdbXlpfj-=Z2C!dMd&HOIP- ztyY@s5ej7w5)-@ca0LQzz6`1&#+@$nDfNUw>foTZ3E@D3y{7ma0Big2h&U|)CHgm| z!p`;o1Oqj49~zd$%Ebw7MF@arY~dC_Xb$+0iB{muS5#0aSOxpm+$7KHg>+~aMi9ct z8D=R3U?!rJd!KRZ1EdeCL@_Rj4=bsbxh2R{nQN} zO)rk(3P-QI4bN@KB!$<)%&b-M=|SvuP;$&Gyz^L|AK1b!36{K{{vpo;zrepN2)t0D z_(9p-erPE+QWB71N_)9ix|GwrMd_>eO=XE!ETSlA69u~>?|K}U_ zr)3G^GtVMo@jqeD(KTP`>IQ#6pS-JoL!WGyv~FwCG;l!0#xI;V?;P1)0T%^4y_~>& z3Nz3|I)T2x(AVF*d3yzn%(5ZbZ2)=4`hN-Om8IFioknO zny`RfDge+B(b!6cElyysEX~$?sZ8B3JU*(L(Y@H>mtbsyheY%fD+Ob4GA2fzuRiK8 zCYWPmh%e`OkshD-a^Wh6eaS+-oO709%q80f1;*Wdwi^VlvA=UNM9HZAnX! zL?kk${rmzH+@2guYk@7x@>tq+F8#`w`wjPY%TDfIQ-ZJe2m3E)5vw;ep*(~vw0ZLk zcMlIhV3z+k3TAe!q)Z(Z zP0Xh{!iA`==DJ&Y9uTX};uix0Cor`QaPPCPCV4$NyEDnQk4+~q@GsmqVj$h9#v`J! zs;_x`vAxIaN>n|h*-hGn_Lsk{-Sa|(x8SCi@d?BhO|n;!Ra8o9(;^S#5@WI|6=8#Z zh1kGV>|}==EUs4nl8KBuJx&}_p|4E}xNc}v1nC56#ki2{DD#wNypegeX`o^pA+N*& z{yC96W@T9}YO>0$X#nA|(Fw6|51+BUMe?8hj7rB{MKbQSh!|t{fjSJ-gh25v*SLi$ zc29~2eFw6WF*VEn78Mo81h|IP(w1WSh4c226ES2n;^5*EQ&HJ{++>~mfFz1#Tc|fsCFU!D^)$yqBwISRPJn1c zqU)k-5mcIgb$efk7~|TF8}Z6UZdO|9R_(z<-0ZXg-~E!E(K<6OS5#MzFKS3T_(7<5 zgqXCJi>_5tx*HXh<47D^m9?*kRYkpk6+U@fMviN#P^sV{|bHMF45!n+=~5CYRY_E1~nX^jX>(YaXVjn}Rl zu!58W>OWKClgpYDeNisV9fyZJOwn$dQuH4qB!JlLu5)f=$Lsen#=F@fa;~@!gj)6u z9kR32!nfI#kT_y5kU0u8?mfS?=0L#6Iz(l{7ev(1Vpnv_1RPVeZ==W258?W(Z*%?K zcg64GlO?sG0r$yS@Yk7ZVvkTC`>kzDrJ#sBb^J@`N#f#TupGM`Tp>1E>@7b!BrvTn z(5roBI0bG8D}@~`F8Z}>NioM!FHOzjeiJL^<3KaNtB>AjrS7?+cW>=G6SK3a;mxPa zO)@r}D%jHTV&twmTdoLw$pT2*zBf0Y*ZMSxYiazMCh_YEqV%7_8h_YO7+Xkt;)`CA4%)o!qAlB zaMtxzSOYe&o%cbJcJ5hVf?m87YP4XXu<&Eaxa%3}KuGVs@wFXWkAR&wtebltJ>xL4 znh{ZY6nP3{vf52AGomUzx4O}C3aM8)<%#Vf)Ut}F8xNaKwx;d_fYr3Yh=%_Lmzqha zC#>h%MQ?#Sx6Rm?SavwKv=QgUf(#@q<}J(q6z6j4!Ni#YEyO;;&}!DUpyyY*yM1{y zdeTEPp?0|%x%Tia$#wJJ$lMz1Bhs*m&paFL1yW#9IKO9az0g~oADG#?l${uTJpeB=3X8n2=a07@@YJA`f00+$I8>&uE4A)Gm1Xt+K zc>6}l-n{7J$8A8FFSOjMrQ3sR{t8iz9IlrT`ra#2sl(40jb!c)C&{P*Quiy$H_VQW zi_;5Ni0&^hr@4>slbrDK*7j-skD|gOW>>h@#gtmzwU5#YmcjLbaSvQ=Hk>^9TyQZ- zMQ|zQ3r%f#Z1EDK@8*w8N z{SOqMSKmhhr#2WSn0K{~iH-vUVbzFjzuaHVIRE-30CZ=rKC6!6k9m3Cu39rDV3msJ z?+%K03{e8sRaBDZfAXS;2AzaS{haj+MwfF?Odpx~kbSY;u$xg4)2}ELL=38-nJJN4 zCBAXG-)w)+{XW{=qivm2qVsCVFTq%oHK))KZ_32dy4Sx5gau<-l8R}#k9 zm!szQ^IUIwYlCh4AM2zwTWi?2wu$?!$V5GtXwNgysR&8-)6)Ypi4zDQE|*?Dj~k9N zXD>&oiWRhYdsGK%hQ_2sMNY5G?pcshonI^1Ww(B{`*`=hLEr3>;8x>hBJ_Fu{4sCX zPNVRG8d)rXjku{=?Mg5g*BHKj?V2@={2?sVg}2sF*SCk#8c>s}9cw;ceO_&JxuLjJ zqQIOvqQyz@=Zb-BRIcDo@>a2lqu$`%v8zR<061gWx=(*{q@*GH={q(!(4`~HWV7zw zJCN@ahm>4hwQI4*uZ%d-{zXU_7aO~xk8<9|zvc0X)n!8akD>gXK7IO>iF-^WGEqHu z*P_gR5_?XFXQDKs?7G(`OtE03nrjy%xou&BH(~Fd>hc~a)oVW_Q?|njweGD#*jZV- z%#l6wOT1JxoQX0-C*q4fA?(POHE~}BqQ=_uNIh0KcR;qP%7kmI;`V^$_MJP=q4?ra z5mdR1fd#36!6;;-;IQNoY(3MiGD;X}eK_&m&!~coC8|I4kV@Yiu71dU+~`|ZyP=cj z$oRyBy<|6!Ht6(|lEWglpG0s7dy1K^x@R0XGMd+`yug?;XKtEt;-KP31735#V|O{cDS`C`<<4Z= z)v^6PGx)VHxzXQVnzDbuggF?3rB&cvl?o8rVn3l$xNgM;I3LYjBa`SCtv@6J;v^P; z(GfEC5lk_u8+%Mln&ewG7unon3RjJe<{hmQIWPPib9cUcSQ)@QbYA2$gl5l+Osvek zs&z$o2}=%U(K<^c&@7$L!Aor@;oD7ugR;2ehUqppK#*plBUE`lO_rr7S(C zeJCW$KfVK3fQ15D-xII#lQ3Q}?)4(Btwj;WJFZ|Z)vK(mY?kq3OPzg8iF*6}>2zoB zGSzroG3!p7-_NH*Fp6&G`f%k^kcubuJdf@JgfQskn>*JRP3rwXNjpQs_)|28_J0QH zejeW}K{c`1B#=Ykkjs~&RKj-xro-t~LFTcGjt*=F-3gmiw0`Ii$ls`WJ`);x@*y%* zX>MDZotAj-$&-&Q77U^@olUf%X(u~xH5S_@ti7C=clSF+B@hD`$IMC<(fuL~z1^8o zy9%}WR8G~`(42+Db)EXs)(uBn()Me>+u6oL+!vNPzX*SBwIQXa+(6_qnI;9o-- zS-Tb_B;Tp<0NL0xs~DWndO)QCOv8k%9*H(VdP*dg-G}OZtJ}S152dqGz}Fg@aclOF zIiVMk;FIu2@ABHSea4nvZcz$j_y#j`bH|*O6FCN2QQom~j_!T1yt4Nmx(L2)AybX+ zGr`ikv4f+kc-|21(0kKN)sLv~4(M8!#~Pjw3^b8oi$JzvL^91)*FP>U?%ie$BDP1M z4DtRvEKTh;-2!>k)-dHe`U_jl8k%07&ENrLWZSM?oNvDH2@Os-drt`pOv9)M-6pJD z$*H8fxq1I0!-)j5^!z%0aiBIB%R;RIw}S2`amyDy0Dpn7IXh3TRQ(+JYtn~)qF~G% zcLW7_S;iBYYRK&29bXtgCU5v}eQrLFLASzF029FBu^N15-mN)~kN%G}h>Q9pdEtc3 z0s+X+!HE@c6f5trFa1p(fNCVMMGIXH24#wIgQev(pDwOeJie;XjKBdVb8OG8b|=yP z!F17-5`eRrtQOl6DkXsm>p(pU@1FKlEneOGOjb8M<; z)gS*6YLiRf{1~x7u%y!HuuF-XSa)f{Ge&CB9l4zen#GCe91v@29AocQwUiW|U{#B~ zlw86oWUshO|I`njzp57ZHj7|x&jQO@s-Bv@m>)D*p0)eb=3l(m6!vCH(^say0*DCq z=m*d2r%kBO37+gHUrRs+7wt?&kl4W}eu}s3mH;Hv*Vo6so8`GXT>ao5uYp>Q40d)P z71ItdJ|tJmVmDu{GMJ3$7QNE+Mta8iUfHLCul=z35!wlyXeZTVpV@GgO(rxzC;@)4 zZ?x~bHT|Yf)r^@}dyj_Ay+g*m6H@5ddl9J04#^`%{}NTVh3pQmNiTux9E>So00tBY zn#Tw`&Bv8f!n`cqlhEj~Te-H%O*g? zB!mx-SjFy`D`8<5fvC8wVVi=&*tvOsHY)gO54-`KYBEs^YzH6#Q83~#wppID#`2O$ zq8@z$Z!;M(jDl_;PXN|X#}Vz)8_W)iR-H*u}Dd*wR#jC~yC%SN1eo;jFg*gV54+rj4@q;0sliCCjZfFEj6jeWFlW zld0>E7dcgYZmPA5q;Aue{qcCuSq=Z0)QLy9_p$jCN2f=O3>g$ShQEvx5eVq$87x>B z@bnkRry$q)`56}l!1BJ-8lADUX(w;yJ#rln4|RRDNUF?xD7UU*#AxQc?-Q$mY!Z`L z^vqytQwRx>Xi!|*`GG4`?xYxHBC~htnl*Fa4n`rEC`7iN;09q>yu*}5cU!QX=*@n* zYyN%+6a^Sr^Of4Yjy(FBa$zbl8gPeCe_3xVI97y(E&yd}wd2whIHZ;j! zXphYLsFA*-H@PDFn}6QatK6jU_TOt10~4*P({8+eQqz``OCJqLPVQ!?SkG6xtKSd5 z%m1lb@$@_rUI=t~Qmt4+pP4U~pchk(+qP=u=8f-pt^?-E=-GLkLdon@pSvQb&zZ65 zlYH>22si$Oam`(}Twj4zmOgFI_Ts?;S)wt#+vOUSLx*niMFWSL0L+UomUTItuV&}k%=N@`R3fFrO8aY z3{5P73)4;vo~nH%Ai;W$aM-M@f-t^XD1S1neya+xas($YJ!?{7RP z7Ak)gY8%Kmqk1ccx5C_Sh2GO1MV+@2+~7;0c+I}9T9at~^zaQ~ClDl{$s#6jU=^fGf53lH>;X9zA_%Pa_39ZQ2-6 z5|St;K~aw%&&K2s{gdB27p7=6(2{wB;B4Sw13(fLj~s|eV=KvL<_X^S^%P3q8@L@^ ze?kEsrsJP@dP?`oPMuUUu?kxhY-icL)u zp+|=Y*iKJ7e}6Tjma_>oT z7$4tgdD@JVEIFD?TFuA0x|QBE>F=72HCw1Vt}eL$r@WGqgPw_nGCy#*APdvAMd}YS zpPwB#kd2Bw{N799Qf)aKH*t3_F1;01j%wL=BqK>D`0jH3mZ<+$x)Z=?~8<@Ax9ciKSZR4o{ zr}OfS8*qJ|I&Bqif|G)@63E`5W`-gVGopbij~i!1&&ZwnCi?z7kHD#(msnQxnyq`) zy^Z@H|GYYPA%%i<`?~3M(Rt5(S@AV>ZZnmN8Ao#N2h7h(;{V~KX*OK&tvtAPNZ7L% z^z*<=&}n{2><-@1<3IA%R!;sv#7JgpCYMM{WPVt8;mRS8XD?s`wZB{H(AhdurqoN`^KsjQUUQ|0G)itY?==9$m+=J*TP6qj|l#L;JbrPk%XZ@F8bA4#sqU`_FVf>QP*CnQJg#){_jvM7|LmQ=LF zws;*-PLw(3-VwCCW@6PNclU>?3n)DdJ=&$miC(FLR@nC`6cA`MoT|2mYG`f&zXp0` z;-Ukk6fsbse5|d#gwi((68l}_4BJMSNAZ!K>wtx{fV4&WS&S;(f|k5{_s$nKGEm{- z0EFwxFX9iHF+vpuUj@{MkpdZSAL6_3m5E*rWTz#UN^w%+v2X3}*4wpfK4pm}^~L-o zOhGUp8u|qvChy^LS z@Bm`D0U63|(N2&bqIvv1y!+_02Ivx){Qzde!1+_=?e%-7icJ!r{#(o0bY#B(WzlFi z?c9$*m&RfJ4%P|(Y9>|ZroTP>Idf!n%?Z-=C)jzvEn!#|r%@ixof|jT813z0p=`9{ zJsA7mw=aFR>5$1kw;v%>p`?p&IZ9X!mHIWSbG zKfZKZi_)#=HS?{@Dhf&MawBx`>ZAxXD|&cP$0@UP=v5iBQU0UL@NlXwe`}vWV8NLi zHv}=%544&|0Wru1=`galMKY*_|Ms6*t)jW6P~aP3mf|pvp!!v*7$fL!+GGrJ12nDV zt3@_I>LE3igBo3Dx60kp_>pfAjX0!Cnnb~~h&biaVo>`72fP5d)IG%(&seoImIcy+Lq+TJ;8OsOR?V4EtdTA={4H{? zNwR?2sUoB4v(DgeM_W48nA*{pXFkOYwIx$?3r8j9xpj7XFu^04l&fZls0A&EP&^|& z@omA^m)~~@tat4@q@76RcGcxe>0%0!+F4!TNRF({3RHaoHrpcaMqrfbw5(8S!B3T_ z*f?e~`%@7=_pq{l`kqjnpx;DLGHk_@_c~RWIsY-eyQFl2S=m(7{P_mI!h*W5FC@k` zIH^<8PSpfu$yl^9nuXFeU-+$4I?^}}fcPSY6bv`&P);u;>S)QHQ#dzH@ zG5E2ZCe}uXDg$3u9yVEBo2x=WP z3T&B0IV*0ilb&3pDR8mbOQ)pcoql^ztiUyfK>mjz^@3A|O`17w{e1!}=POx0S`)!ERq)RO~ins`S5W>jM zX41QzNBih0?j-)&dg$l)`T{sT5&Aq4o6MN80ZIoj=rPyxCpZip5Lzyb8{p|u2r!hG zX#f|uDvQ}fLKI5x>Lnqn)8OVBqOjUoQHjR6p+KNJ6&5+#L~RXs44f}#&z@ZZOcU>n zl@f-4D&tfzk&x7>HI-OUiWvsK^mf?G1(@or^hUuUsF?4TFbDy z`IocG)0vd?@Y!MO#O6x zu605LDHl-Wz827Q^dsePX!e-mk+pRqZS&_W4pwTjnKQGFD5tE?V6)s|@lXAHlOOCnG0q?I#0Ji}<)D;%XqXB`R4WFFY< z?f*RcX12Ns40oj-aLdQCdOQ4W!)hKAqVz4&vR|dZY~(H1_z`I@S+A;U0jGNW+@r%^ zL;mvC`rDbnmWlpsV394=RwgUUb)&O$W~W3;DUCkA%R3>{sP$1bzFd?&w)fEW+Hv-l z;jsCI$~aG=6PDo=x_rz!@3K=ocFqfeOa;LMyz^ z&HzOUK8eQ)}&nR(%}oFk;Z`M#E3_Y)m8^)dL6Yk8Dgr zAR0J@7eY4n?dFaNF-2e>yCP~VlB9s>=wF;jnYAD|poEFeib25xeqO*o+b&cd)96df#1?OW-8c=)#FjtB3r%@{YimA+Rx@oy7fq10UI z<2UBL3$@%8vv@p`_Hg7|-M^UnlGqS1PiMf{4TBZ5V0$UBUc=&`rER+c-yt2?Gdn2udKC??cB3<)~ zwxJ`fZh&bCY`%SFZqK9R)+K5yq%V#B0nZmq^yFSWM&*7|d`rP%r;|tUk1;8#l1JP= z_TM(nRJy<$Z^0sGPIH_UX4UWgT|RgYSh~r+D#kp(eC-^ZZGna6oeLxzik=miZ&E5g z$?du-n9J}rN^q51TfBXLT&b%kH++Kj&dK3&zoNQTq6B@9iHPYR0KJlsbms7M{O^M5 zZqhdQraP6W1ys>R0yFS`EBnw=HV9hHlHu<4(&_HNbz!VIa7^}EVfLkNrZJzT*%(#! zz|-#ACt?j|3(HQHK@-BQeg4EBI{YkV;lx1V5AE{q`IBbCvBoDR&4m3_Do>r{6?p0qq~U z=Kb;IGoAzVjgDr8KB=*|93kfS1KmpZbnm-D{rR%GWwGp)x=xLcl8aZDjmh+t?&_n9 z|CQD3BH{7&!mQ|t7`c-nJ*Y2SuKbg!aKm)|Z-=^s%5)A=#V&bH4!bw`d4#!-K4gN~ zE2dJcHO(j@=pJpHb^F#sIgOnw`CcAaV4b>ZC^L62JDBgY?seeT0?;%aVdF)89uD=a;^zkXe74Z}86bvmYR5@%HV6d(G%N|1YX zWLZmG9CKhOZyO-W<+tu+B({2amF_8_mYM#1-IrFqZ`PhOq^0(F$gq^JSHx}2N^S4v z;yyv2ct7Mnr`5sEjyG3oa4rAFo9@!0jq5)eYkUaT7A7IAL9srp>RCkO`r}75%o@Q_ zwm$I{uvvu;F{wqA<_64mtCuGL!=Tu@{?v z8H_Gd>N;Z~H!~vl_(_ZI}|x|D&R+``j`nXpoA zcA&+PbX%5FBWWgHOUm8|j27sQGNdQG5~?e7`#x0jyV`utJ7h!?`rv(jr0K=!7B{(y zndx~8jD7e9{-&|~O;3owcuusZzim(DtVe~-igTUZVd3*O9m7Q5<7En-Q zI$P?actR0J$t?@>m{OxWK7Ky#ruXA_)4V!8l+jb=wR6!TMeNHbkDt6{Jg$aUwXS;( zm~Oi~{O03}!L1lq0z0}g$ffj1O&F(Fq*FHqpFE?fXjeN z7qTWZerer$?sHY=SyM{e{ZY&mv<5D;rNtEwHFmvVew}_{cBR_u1-)Mc>IVs(2vGOd ztnQCzee^D0OlGoH@`$MyYstM&qum+o__5GZ>;`78E4_!g202^?k(=?!KAUx%7t^GG zp61VHyRQ17RcE1~idiW8k%K~II$n2G>mMCI)&1_AM9F-XyFQFdUod(NXlpo9F`F^5 z@M0~)B=w~`WWfv#O`6c#ao=m_oK_W(=`4|ZR|{kgPTWcDuB$)lU{tc4BV+l7sF@E+ zvdYd26H0NfnGlD9kh@2>QPJVwZp(Z1-_W}iF%20s?{G|$SJihsI4cImRR;1vgUvtK zEIto+4_8-ZcuxX!&|$t|Uh+(?)GtZI_pr+ez&8;c^`FVIzZ%p>UnT@JUzcy$6oIG55x(rviGWJE@&3jk%uE zzd3aK^oJd*h&(X5ijUU;ou;VMGJGp9%cU-($8L*5Z`BTEpx2API35`2ukGL18&!n2 zx!!d<|Br*xubMB9pQ>bxhdUkZ2uyU2>v$f&(oJ+JE7oJi(8`oHrBm6)zlGqRR$sth z^uY##B6XLohnt$+(@#d9Y^f)WGIf5 z3vrAf{gQtWj6$n5H%a^rmL}`dCJmQ%M$j?y|GmmQ-Atqd4Q)X8;w$8bD=1Vv^or@8 z3oln=8Nj?mzb6z#!hhw!Ry4T1*3dJ1Hy$3id(87HFD+gAr340fu&PIb+bY=HC4p-U zRXX}roZ0wXx)uF}qnNo9PX!|P|K(Ev%!#mT+M_4IO9hsgm>5b#njs7{^=Ye=l}C7< z9!6Dy(n@BXIqoWk(Mc2AJ zfPWP5o7FNOCsECwlJT&zqUs%{QW9NU;xBmOI4yn7 zYG8UJ%FGy1!!PJ11F9=F^u8#uKKNEG=XfeZf75v1J)lw=-F?+H5q-xmqZz%+99zw2 z=My|dZF%>0OWHuKL_l%_SfDir+CR8m+zp%Wk6Zle9lrxO*l{t#xV*jJ zXJ~p5BTsUGjr0D@pr*apy|F+16S?K~B--j&ccB)*168SR+k}(D;5p4rg{_Hn^aun! zXy0%6i{U~I8!=73f5cDlXG$ZQ#=RpixdgWAHSQa%)1vdf>0h{&nOn%Lx1uY5bj&Tw z_FB)^YMLEu7b4QH)jd;i!=Bl@XlaQuuuGiS_vHSJ%JeM8B2CR_i%-!s2l%eK6J5~* zNYg{wN47&Vfp*SiP5tzf7;^5u*lJup78ds3>(eO+*AZ1$x7gLk0=g(@L!yYjiLp<4 z;pw$+H9&l2=f<<5hofxH1$WQs(>P>rPm&&&IO^H`T1h}}ES$YDkRiIT=tWbEYi=Y$ zvQXggQBWV{EEjY~<$XZd>bEj)Bs3>lP}U0F2N0;d= z^x)Qg)<%V91Ft+o*#KRSlZqkJb!$@O(w!3=vmrDu`k8g|rO_|x!?FIQdL4N?8 z=D2(wPucJf&u+kmlh8SBiwWIuf0m_b%+M$R!$d6swUSvWn5ukLuJJLddoZ+T$+Wu_ zG#@ohT2E!LU_E_(nX&V9^7Ea#zUH9;c~C6u;!dRwRQ3Be80ZPcYgOC9$@n!=h(AIC z7T2H4O@U(y@E1#;$EDlR*T4MsSXmc05mE17)9-zg0X~Y&YCu)Se#YP(&;|05uI!I1 zpXl-qOyaKE%!m5EEoMMpysAnaj7Cq*8y(d*QD)V$jXTzUm=;Dw$|Z4S)bAhWo;=}a4NVX2G5Y;aB&rBu`raL?OOF=!Nb>O&Rd(kmxu~E zatbw6_PsQ5W!}}ajdJ9EM(n=38T|L#Z!WSs3a%FTeRE92VJ-16@b8#ij@Pb;*|ph- zJ|2gC{ctAo1;)cwJ+!J6->$2T(=qg4sxa+`Qwe`Z3HS5ZElYkIek;_*>$xg=TV9fn zq=%*p23>*6bOABtAf52uyRH6-uf@NxcN?ED+T&Mz9IzBc38lxHt1 z1;d}p&v~#>AU+yfbuoyA>rh3}FA@#;E%+<~WGR%NC)Xi6T;KMI;~5wa=7#Tf&|6HEcs2JfddQT82+ZuxGjSh!9RYM!m+F~ z7GbY1`yB>%5ReZawiRM(gn=v|o%@n2;T21iZWwh)fHi^&0AKloAC5hAMf>IFkTqb~ zA_hlCu+`cR5r}Z=SM>CAxzZcpR1H(>%(ZSib`TU2gT5U3zK1Zjgu2_#$%#vCeIkNN z;vHCshb`d(MIrg>CyBqhx;w?;uScAJUfp`ZpYHiM0PdjohxEUFRpeBhS4)b$;mQ$^ z=h-6KxB>$MjWH^OGzao&_`XvpB-FwM+MIm5$FZ@?NpRoKCYb)>=2O(}W?(cIE&b(eO>SCx?XPrCu>WrFgf}Y&P<+RrGamruFdHnh4xwi9e+kx z#&kbpuE&RhuE3d)P@Eu&6XVt!w1fvGczNV3MH(;?jeRH#NKJ%E(NG8~0>uN0GiH?t z&+)#9>1sPWI|6`=x_9fAGfD=O+;XQGtD(_R-a$9ezZuX(eics-Sn!q$+{Cpg#(PwajUla=HPdiUA6vn$+Lbcd)Nm{Pa}va)iRnVO8+d9V@)`t2~M|zoz?_8KtOB`gbm_+E(Op z1S&;OfBg0x(t!JY2sD|B)tog@u+A~4K@bgX=2-)avD7fGoISCOf~Li4#b39Xj6U1~ z*4$?L>cg===nw+a+%>F!Dl5MNuEAh;7VQaqUUhKQg2k3AN}3dDOkcGwYb(M)eJE}g zoOxj6!q8aVSGKWMqkG<2|T8%n)aa_D*ii>3f3yR z8?jJHPePAbhQw%-)uWAp0RgXOIs!#D%#WJQ-8vOExw^HY*wWm9G0ERjTv9UVzoh*3 z)p^xf&u!KNG+k6BWZiR=AZCK5-eThrmv3OcI}#KYipxW@T4evMa9L*X=!ahWk4pk6fk2EcxE6xO?c>{{ri6}*ubw=8B}niaJh ziqh-JMUjk%f6DS{poscBre9@E{P(V^NTmUNguNEN2PA!0lR-ld))2lcZLVS(6^?`| z_GWIKGs?@4{K2vi+OwXWd^*C-7(dFpnddcwW@pbbxfHs{xvg|#vH`$@0}ep$81RhW zwB^@^RAKW1AMF+fcE!N(l$09d_qvcP@-O}~XgWjC47`iTDwyiSn_2K|a4`3TrRB4w z^wWd)s9?movdSJ+Q`;=J3o5fm_bT~=K(N^~wY0<+UEN{A6SDY-(1~1Cl^iOZ5NthP z!TfeLS7JwRgy$;(#{oL5F?{ua0&bF!*Z^(y1@TkG;6}dt@1NFyb6%Xq)5OGzGt0$_ z;c(>ev70C4%HieLqh?c>7WytJVA*+sVk5_Fxmvi0*;<&(jVZ#zD!&~& zntNfTiGZouD;HcdjL(Y71ptMIQbull@8p^fgYWL<6pdcOO@iZUvI%aYF;V-EkajwF zIeux*94Oo0k6Z1Cvj6@2_lp>{f8Dww=P_y|kMpJu7r=S=7LGA^pbunX14U;SlgmTt z$+jB;ui6O!+Gk0*twL2-@#By1D_FDO35W$g{3n4OZ(U{vGhqyOnG;?MI&;F6fGI-% zYpDJXX(uz7yLv}h`4GziZ#3e+(l;%ULvXW|Kx`i>@#!5U^AX?NlJPv|w_@?j#vU2=9PER{GZz~=7!SnwGVx!PB?8r+f>Ix{3yl*5 zV*``J1M>226RMs?KaMZS*}pO4jm&?SVF?pSpshZ>&cl(y2o?cq79YH4$GiniE4eAs zh+=R!$uuJzgxmBB6A?WCH;}gzR~l^0yT}2TYwk38n)ozTcSCY`W>`w0VAxY0A{@4` z@36$Iv9=p}PDqFbY?}^Z?06w031A&b1^iZWDay_EcgbJ=ul0%(xX*}QF&QXkywA8@*n|)fqmm)X_H%?y(*Sg!f@%hAWVn#1Q|)S-+VX$BKJk zWf3?OP9<3#%;3s4BT{K4WW_##a|DZsHy|Sggvhpupx@T&^9TqD6l8cHlkr!weIE3K zK*{)(oZhHKqKnHJQ1@!Hp~`3&)1hHr z#@=3hi%P&hU=)ZCSF028W)&OTb{Y%F#wgY^>%kC}1+jeq4z3XP7(m zo<^vr7w%Wu`2s!D%i*QKMx>K3VL#iqCzQ!zbE3{@U^X`&MbssHM zvfXdqe1+ReR#&O*qS!!Y%QQV083lOLQAP6*p}CKUoz8wy<~;xA>AdRj{g|k@4@r(g zT0b()y!iLsfRWLY_rEhz@awesFJ=z@_W_J^vlsspKZoJ(9)mI>Gq8Bt1UM*eeS#x} z$rgqrArLG;bpwnuR9aD5VdvM*awoE!;IEj<_g^Sxi!(WdFb+W)V@a2hwRJMACVw>6 zl88&}vT`YsTli=oN>{K-*BmQWb-Y44VNdSM9_BD4L#80=1Fo6JKi)zdO^hesoamL> z{x$r#w)V_hn!~3i&%)>X;XyYhQ<?m zA>XX0>0B;+m~;lh|6m@K-+P5xY{0&& z^ zz`#Klu=Zcs^CJt7yg0Yl7_lydyV~_E4`M-&`AX-lqWYl3AvA}m}g7>?is}( ziz`PEMWG<1!XTaiPW=j`K>T7o1!l8|x=EfsZ|erBz#<(vm)%wwqc&r^HYEbj_f=$b!(q?%Vo{A2KbHzTPXg7^`OpDSW@X{AO{ehOF!& zBtQ^j-wW<**z%S@Jfum3={n26PqCxhpakfNDteS0wj zt|y|q$+N~$vAa}&`br)X24w89`(jMecYvR}dqIT7n$Ht%{ruzt@IQlK)(Fwq%Ahpw z6H1|A74$%IP*Y=UW%IOv?VC>yR=S{8U_d;Tr}&q&R9@`{i<5&+PSo;tpN)mc9TV)w zE#oH;IbRo>A;B;@%M%4~A|kJbQ&1S(hqN?rrJnmPYsrdqM;q@696*SyM?AgSvo3b8 zJ`^O@xjRFl`4Un&q$b3KN*`9Oqg)k zRv1-_s4)T1Yf3IwHxwM5s2k!(F)gwBOiFW_1ucS-9?q96pnK-uSo_4!VPZJmDfZ9- zY!`%2Oa}QOk*8;#-H{Lj)8Za`y{gn)bm* z!Q~D5NKx@xi5Gquu2{0}Xoo|;_soxw;E{(Rdhvf6gcjx(#jX8Vest^jbIy`ShuGJW zbP{3#ql|Oc3lkVwpd~+S+Y=s9QC1+d0*2Mq)tL?DVNw6X(??GSOA6Zz(fc@hn9d$F z{Anp+)z&9v_-Y-_VRi``&0rgxC^@W2mk;WiRp~^#PHPAwG@(9Sl77U{5zB`xceIc&QP$RH&*sGyaY*i zAq=Lk2>T-H=fuiKjh(>s#kdB>+P!%lLlDa%Zy*~ec8zT_sU5`Z5PRu1FAHG75k)4p zgz$1}``&gTxJbDmI)7 zw;wLz-#u=!Sto3NN7xAftAWCLi==g4Ue$%*&6)6UW?f|wf9gD06mcnJDLLF>+F)$4 zuDJG>_Y_+Z6rNX~ZbDB132hPLNW69~G6!84pj)OVTpm3yUAUChRK)YW-Rg0R6i77) z|M4)wHC=^1o+7#P(Z!?9Xih}zDrmVK2La>-=dG5SYHAOode6LSdAZ4x24Ev{+ErsP zZBU%^)gn`IG8*(r}5f)VLD>-TG(#bO10H#A6cM(X zR!)kq^PD>~LD8tg_`C}ql_+pEuQk;{q6E8ujP5cD^bY5>+BSdXo(so0`ek@;XW)d* z)bNCeCKfN}+fK{$uZ=A;EIN+kJR}8ZnHfoKORhE$8t-$;rcyT&VWSatj9y6Z?NRe~ zHV(gM_bSRhI}^zQ2%`seXwV{Y3ZLjE4prwohs#fZ4JHK^x|FTeORKJ|RX#rD~< zc{*FnRN0UJyx4mBH`d;9+SxIO28C`5aq(<4wVJbPp@{6+bDb`64$t>TDmo_GcjL*J$pD*tn6D?cCGoD^XFN-BQ|*nRLYv{GY#Ql%M)fFk}RjG?>_}40~)pu zisIPOeE1jbDB3&x+*ZHBWJ8$!Kktx8!EGKsIXoP0*RJgtoFdIJxKMHVIOl~ zyr2J`lJF+t?H=tX=gDM))bYe~6Go#q0iCs`n|_|9_H1kO;&NJ?!VV2a_m8%;)`nb) z@v~OfxqUCi?GH(w#uH48jGH<78*84 zmT#oMkcPAAQveBpKFmIG`c0=L3PPxIxHB4f?=T(Gg2#@SQYzLC<5xtmFQ77HMc(x@ z=jyyDmOT^s9D*AIiV@CR-}0y=i!nlrnzM>gmZ z(;EmedCI2oYGl5jB>j*8KYXrF>S@!iu3G11NbWq$CR_k#n+zRCM93~2!m%v+;^*!& zbzXB}W=>YU+s69xu=dFQ%$A_k{xOq0p1W^wV9#pW+ZYQ!2pwa zq7Dn8EEX!=-Ko~GKpOlYo2JUjM~K}%9O8>|v+qlUwl5pZl3ku8J$hL|VQH(srD}Ex ziV*5kUvAiWM&M|H!?B$(@24_OtdKo?y%T|u21R01-`(WnOhfr~r^(-mqUp{Tfy;fAtZRKKK$Zu?s!A%BspqpIQq z|A=A0KTJ+#OH~5xF&s5>+i&k9^l{3vB|jVjjeP#cr|Emk+s=%^cYIkb7n%LGHhY(+ ze@Uxoz68^>$qR$Nv`({50*?}{I@WT3NdGn!{Wy4jh9qbug_u21S`nht##wzMWGlMp zoS7P^9QiYC?d=x$(ln`#8z6oVWGHgQUCxaCm|De39;#`}^#RQLWzVeoq+(@~9$H96H}i-!&uKmF&q z_P;ZyEkCReX4D`wcytFrKn)E$xcGALoBM(XHJgr0>0pxsE7A>#N1m=U=>@m5BOGpD z-z!K6$-6+@gbW2@9aCRDs1tJqJn;qD2wVuW6sIZ9>^7Rr#y%D&jabn0o||Hh2ZyK^ zt^qpzU*sn6R}Lur;_Wz#VjKe;IS^Ffbg@p+nQEj?2QKA?bE!$(vEf}r$pe}LAgzPI zn14KFH?s@)Rj67-{eoLgPf9uJ|yomb{M}C86RvrywwKcMJ z%g*a&sxQ~Fv3YD)k8H&dksE|B>=BFzy?{SOeTx`}kRcrWAzGjWyuKwCavX$U+sbyw z7JCM2lo&tS3|BcEZh8g{hrO*37ddw|RfjF5A|57^F=dy2!HTI53{KJr97tS3608wi zSDQFD5-2DMFNu1#e*Mlpcugn;?WbEZW@j8A=*dbHUznH&li;-25{Y1zB8@x&>ZZ<6untD`2)cim!MzXJ+25Cee2cx9C^7rO%i-#I!N;=n` z>6m73@4k|7>N`W{Pz@h=2UAuIo#qVHmoQ&D#TVOEDsBCt&{0)IMb^$P15NZpjmS>& zzp=-3%Di*$OnPSPoZwi7uXdJAAWnV zIo>`-hL4Q(A}m1EwS4)NH%8y~$%cw7f3vYr96fO%vr@!#5ZG^?8sV#3S+{$NC@Uze zS|E^&ReM9tojNld|E@kj+vdX}>12jxUH|t+o}N@h&ZPFHcDxT{aVXBm*6UyeI1c)StQa3d4EAAoFVs{NmU710 z=wwfVDY_RmT9j&*UfdT{=EnZ%%GRPz?^aI^hRal7THt=Q?0i$&hvc#v#86)+TV}O= zYFb*nabdW_+;>NWKZ6AANOv($cz1+--wvA*D+B<4Jhmo+64FLyYx9;IPh3<;IiF64 z`sSEMh02D8>j)2a3}$9#R>1k3Xx%40L>dMI&9iE2vm7Rohoup7fUy-9|B#>kvAr@X ziatBD{+3)dWbV&R>FPl$YaY~W+vE1qg+LF`rJ#1VVDa{C+u_=C=J{qHYO(x%t+WdZ z5Fl!-o#XxxeT_W{Px1hhC3+6brVAN-G||8`@}-#$-%NqNJC-Q>#Np&^cZJDC)Q+*= zkbP+#N*dMmGLCppF4#f{YL)DuU^1UzD6O^oWh|lZKWBvD$%wnNhdZzD?{>r(QBhHI z767Jp2$rf9DX<_aTTol}Q#WLP0=-+*^8@RLzcZPsq;9^R9!GXt_UL>4P@L8mEz&iK z=md$o_?yKmLHCyKevf{r~bO zhlgfnCe8Ep1ZBh2)W_GMl3%XE^Yii2KG88TzTDGjI*GKrqV@C76Zq~5F8uM^f4Zjs z=B59C_ONWs@l7m-qbV;aq!z?}`>S&7Ln`Qdp?BTKZF27RbjSJ^)XV4tX{$-=@bd7GT7 zja|AH9=R_+|2K1cT@6<->=G4?%FFu$%?PKSXfKPO7~bFpAS~BgW>2}@d-pz;-V};| z($n{yvGM~iMSl{vT%_10SKCR;`ZBJGHSkx|F!q&3LDPe=1-2OijD3;y~D`>tq7 z3lf52Vs>WcnHbvn=cG_vM`5toZFT0aL@*ZBE?r9H-o9$7!JRvTLPDH%#W`xr%Dg^){P;XQ-PEkHswxcW zRr_V`)fJyUMIjPV>B>w_ju;=e#TOaT7Zh|rGo-Y$5?Sw2b#?uW9Ps|k{{DMJr#Kw_ zZnaRNItFYyE{KNHP8{7AY@E`bzoRkHEPJ$DXgFKwvsK-Ikf`W3@4~PPqC4GQK13M9 z&ixz~*A9Fg zhfZ3Qds&?_{`7_Dfqnq;7SDad^s9)7NH{QPRf+g=;8hK$?Z-GZt0a#jN}#vOzWiHMfzoD! z{n}8lh^Lz(8Z+^Rs!-AO>|86s;mvq)+w-_Meta7zZ+L6L0vaegJWzM}i3na^-Xs>q zeO~2Zcl+&&j`Nr2`TMg$7*g~N2nIjb7XQY1qTZA`V6m3jWn9$!7&JbtgMoNIK>@*&CM+~GaycQJ^d57c@;S;l-u1846J(n`lexj zQ-_rUXxOK8bTl}9-tOZP9>tV)o;&q-l(*~_6nu#MzyUJ0Tp-*UPeOLSpvUC0{)))*r0P1) z!geTY`*+dt1=@zM|FJtrTO?{3X~3}zO(&xc^d&O3BcJ`{*(EfWBsi+H#7UQ#bGq)`MqY{H)aVjv9a&Q#?lh2HS8|%-f^+2u0)5w z2l}&Lr{yFj3UcwjY#%7<`gU@pEV#52teZy_Q3VAB7^I5G&zE&Qu-Vhwo0oOP4%QU` zG|OB?%fna&TyMXzu^sy41Dd?bA0hbSaV|;N4!){Rxm79bw%lL#wqi=TN3}$AUESM` zj&O^XDybT2jkxx2QOtUh23#Rsx;t5_hK8O$zod%E-gi}1T;7FTiny~R$-vs&PwTLL zrmjXDhorp;-w`4I=PaEO*bwT1gMxOyQ-uAKFRqfi8?H4qW`6J99XsPQQjmS;T+W+ zS38h=nO^>K&vaQz`&~ze8lLyu-J>l|dsAZTWzrR-v?t6rc0`pYv62fqb?Q{*+qbt+ z44WAamBr`V-EE4Cf(Iq>!UpbwJTD%APgK}Qyi-ilyFsno?SS|aXc$Z$GB^9P9@EWV zRBmYF^UJgk7-O0FK+t!JNRq48$s$*eb>`|9fIqEOaWJR-V&&jc0J zYqUJxt#P%!|8q;WN6hOrdxGwU3d-atkL*;mn;J_*&G(p=*7S?=k&%?deiRTn6&vd7 zflLli<5vV5v;z2TZpEaeq{xihXySyPTZ)ooO3HCi4^YK8g6F~(iCtuPY(PJ38ZzWX z6r6(K1cQS*2QAO>0Mj;#royESe>bp9kY`#96-nWg?a$A~3HT^{f)a}FXtJRNjvU(Y z8ou~)nN}?3lP^(;kx~U#7U@tA+)MD7f_*hLH9c1uBzHA8+LsBp4EXj-h`$y*@=T%z z?Je6LS4G3S{u6XP-^#eX)=!nsedY}5l2KLl#ux_Y<0@0Ls+yWeTnCs92YiR1Z5Tc1 zufJJ-AfYtt&B(q_mgz$S%pCz`P@eQBz|efQpBRExY9Tp0Wo*jgm+@?%TeIr%-{ zksh$caPLrhazY&hfvC*P##2)V9H}jJ9BgbpBSH(GDj5xtIw6!%xY56%PBv~j z`ruGBckI)rCYF|!ryf*|y{>(-%Uswqv04(P?=@@IG&D8c<{r0~M4(6v4bGfVf-Opc zN?3Z$k1tT zc-aA~lt5yz65ku%Hp4znH{+I0|EU~xc?Irg=C(=gqN;U%<>t&tBD|Vd%9hE{+lszO9cR=^b{{jA<3L5|b literal 0 HcmV?d00001 diff --git a/assets/codex-model-picker.png b/assets/codex-model-picker.png new file mode 100644 index 0000000000000000000000000000000000000000..dca8ec9bf291c5b4442d1f748a30d404eaae21db GIT binary patch literal 113365 zcmdqJcT|(z*DZ<-DvNTa54_S`HRpS>Wa~l$%h`}c!Ad-wLjOL$z7J`JZg zxnW`wQ&(5F+$b^lkw8$BmzSrUrK6Ke&&bd&a_ASk{h3ZOMordj`xdONGF#yz*X-@H z)Z%2b@c4f)zbpml@}I=VpN4Cj9H|YGavt-an)<`QvVD%(S67(nTy|97Ahf5dA0681 zd-%w)V@dElBQw+gHuK;2yoC4cE-o#VGAVzgnW`3Z_QxJN_L+_}ZMcQAUE3pcMfwa} z>kdL;p&q>96YnlMsVi{Z6>DQ`q|VNsywW7^t@SjsN^i=&?PK;ZJQkwcce~n}ur%I` z+%Nmc=Gv0~)b`lN#~hj1=}V_)WR#I-)!Y-bgO1(5;O~T4bHdo2_GRIMmofEgOXK_h z&Rxr4n8Yi-$e^gIHXhGAe28v0J~EP3S6~0=%-@3s>=DYJspU(CcO}{}^v_wlf&loTmmbqpr26ALQeYzVLI=Fowx^N$Lb;>c9 zAAXJ9LpQOTo{=MSfQZX5Dk4a~+6m(T4-=9Sr&rQJW})*aRC6pTzPR}E$`nbZsL$yQ zIz#0a9JD%sE!As-A}-KHYFn&80&~Y}WbPcE&@*V=xUui=arRxoto)bg=;= zhwqz|VhpD%9vWu!unt$-WKG%HAtT*Ws?v)sC`ivL33d&!dsiV_tiIt^?&x@cjV;%Nt+#x>`jts=J>)g7OYCnxSQQFwl+HB#KpWn;FeH*fPf%m*EvFX8nS{q`Ao z!Hp$Ko5N#*F!|g^rLG5~oR`zz=;}@#Z8N&F6|+w1JrKi>&WfBuPKE2cSuk)TE6a6I z13P##=JKTFv*#}QcjgASPxQs?F#%J=&3C~zvqI*PPI`v+W;m0wa&%HTicw&xDoe`D zAkA>qYAN}2*w#{svbcEIE|WnPw^jlP6C8Kd6xdU3br83 z&$vMGUH$OS!%i%Wq|WrhLPKl;iIPGWuX;23vL@T5`z^5kW(-yiv4|vfTSyxQ#-d5R z-Rk|g=Qrlw8xIn_GspVe%1)qVjPmBAQ#ii#>sak~GI(Ml&JjmKdr)*B6Y6*^_!0Ty zHmCF}wY7_u1?Rn#3INh10ZD0@~yD=k*(<{5!)XfzrbGLbFnZtdA$(i;z zR9^YzTI_H2_20g>RGMUKw z`hbp}NL%L){rPuC_P8C?T6wlaNin=XTpiF;=9WF0d#8#%IVq{@jjJsZUp)nO1zsDj zv;AfIy+w}X=L~Gi>a=Bb%ZTFwde-v;#88i{pR+5M9Qr;&elu+IB@F#p7@i_$$8}~J zv|H2Fho75*)GFn@S&S}g-|xnt_W<>xDF$t&c4p(}pzCB+rC0inpIa+KGxGv#IEl1E z!rUSw(*?O3#hYADdm{N)9zIQ9zniQUXS+UVQZWI{4jgEdCl9joE#0KB8LLz zT@Ri~JB_@Q6gN}&BEwYQH&jl+;O@B=kDA^<#=Y znwh1BMSUz}OC#wzNp0|bBq)wIMdwS528miVg)>bcX$&R~BDrAqE2+FQ3eP7B$foTR zt%;34gCR4LK0n?i?y+o*dq^jH8AgP|^S3<4_F|XC?h>`=%dKZBjelPs%*oJU?ohC5 zIvLk2zP`b{cjZ?)L(eifhE?j3FGqAt(tQ8r*7XSESt^sC@UKpE*8R$y9qAcqtr3nD zg%YH@P_ja~XZuRD&8z)e;p}#(opf49zXGVv5bC^R?XXn%d&0Om5akrI$P~+BW7WvhA5{t88 zxULjeap3Ub&b2xxxDEk6zFgiNSI`4~Cc8_dDruX|v<`@EaR4y5P@B)bJCNezz0tdk zh9Gd*zlr{^=wpl#wNiVryR3(&g~Tgn-0$)9Bj7B^D4oC3GqPIsmEKNfW#0GtG%U(c z%zx9FGdvAvb+hc$%%{N{Sp+Q;)qqo8S4oLhjJhWGw>q8;oRaf4=2egFh?jpdV-TYi zC+o$HnJ2Esq7VAUxgT`hWBX18?}w8l5yPrBnXcLg;E3NMS&5bI8K!@-oa-HNE*UU zciO=E<`)oXFSPG%D5YSD@P7*D=FnctiaY(K*!8K5Y~+ncdDX0!#u`miW$YRtMFAKs>pe+kn|0U9Ac9Fxsg&3CE-{Y9r)@fZ_k^E&0N|<>C6{`0M8Hcdq0jq z=IOxsvrFsYr;^zi9TS6kdw+d@BW~Stnz&deVEV*l7yY3Yf$Z`G`6u*~3pJcYR@+cQ z%D3or*at2@6MK${19Pnk*nW@nbj3M3{VV`mI{Mm)Jw2?meK8q>mFuOhGfkwHFS}NT z0_RBVgJlM293}sz{;lD|t}7GW3jKalI?hNcuax$oI-R!v_3hP4=Vv51MlJgQCFwYN zr3VKeL-Jv>>_#Tf*4FAEk`)`qDdL5)>l29{+PtjOX35FPlZk<{BIFvVwT?Wikp_)- z@<&U~MHI2I3+QED?Cq}b!sVH9PE5hQUtw^1Rz{zJYd#Vq?M%^nP``!bK2}mm>N~Ib zs>&%wwoBp&%hQ7J zvAv8O9Z8s=W$MO!lttuXpL6r1MCDpQ^;`Cgwl*#Js3Eog_t-1mSdF`P6A*yk&~Vej z%1YqEg_~(@|dh1mZL4n&7w$il$q|tX9t-*7+l@y zOHK$Ge}pN%^%vatn3>B9Dd6^&^~ki4B<}m#KJmkjEl<|V6M9&k8H7lriHrpEb=;R7 zUPhW}cQhf8lK5CJKA0G_IT*mSi=;a>Jd^4mk6kaE35F0U8Gb7*2I23u@SS;VDa_2f zyZlVi{caKB+E^6prTgKg-fI@3UAh_?8uR+2uU@^vCut@sM%W;A6;ixH^ylsZzi4&vz6zrgCf65&P@gex7sZrXa4`@|tC* z#UEJQ%CihmR#7Q{bHYs6El;$rXVcbEDiH;CWPV{`%{VVgM5v%q3ZvYnMi8$$LH}^E z7CR&%8yg!|G3$5hE7MjLoVzfC)Su}G4|GD+L~R}U!ah`bY$jbVaL7S?2!Sa1TeJAUlgjk)H|y&R9OykSNa z22TQh8*7Y8waX^z!4Eof?o>7S7&F6s0AjHP-0v}4KpuH;dAZ((ROpO2VvbpQ3c^h6|k5cA(rn{o_ZCn>YO> zf6cg6Zp55A{NgRUbP6WuTts$(^SGKyS!{N1%n*~!TaOK5HF1i_==*SGK2NK^81$M|$tGpt;-0&W&t`qgy z%FC;}YOsYfz4eJDeTnpdfsVP7POnUL8y&YuomE}SbR4W0dsgf^Yq&KgQP~QO=?W)p zffJwfg2Uqmez}U1sxdh^S@+|IhKEO~HzhAF&24^Qy}Q}_9AXh7YkoSbXu$Tt?oNV98B)*rxVRJo(Ufq?Y_C;!rSx2}0#p%gm?i^nAKXF;8|b#s@e)$ne+GnwA2yWX1!6iM>kgICFI0MY@z~f%am`kCD7k+RB-oTGF z#n{nX&+NF|OkN%XP>U2W1i_Dvk4JF@8w-UIt%Lsg=O1n)o#vccf-QXj;WeYm}<&_<-mMEMK?%^+Opqlf8>hq)2UZ-HdF} zYeCtZsb(_kKFdRl^O_H`k9d6=_YK|h@#*BG=Bp)}ai+T+EhsEi{@_*Bk*LI|8@z$y z@2T|hS-L7FHT7PJL)h`{TMo1#f>9TSJ12`>=LfR5l-`EhxkDPlN%>IAs{=VvCA$<6 zz?SW^Ww$cdZv;&n9#@(Lm?1oynUgaco}B06sv0IGKiSKe?n9%7=0jw62w2@vS0@V= z^(DYsK{z1gn>UxGcG|)GhmKb_Y7AE=K5Q39qx-cSVD9lruv~AoJr)S?+8&|D3HmR! z`#qMmbV=V;+G{(FBMHC9_>M03n$fi6VzH^mI#i?Fj!iLFu0848T%Ff(8ak>QCzr~) zTKYJ7pk_|0VH#{eHy@67e?_|c>0``b2Qq2vZ1WMmAJ>7(TPLNAgLW@;-1ZA~xXyynskT1SWOtdAMDuOtzl|LGER1P8jE9z^tA8~I zLb6C+NfuDPal_j6UHwI?rc+BbJ@(0}yG-Q!OZmWZm>7m0!5XNaNNRJy&X+ozq&Tcm)j?y{lssogNT#B6hMDB`r|#< z*fVRFV5SifKzfF;IFX$u_~q}+X4Zdx|HN-t_A#4ze&{cAi_$(==f!17CYV^VmzY)E z60Lu9aultk5hLx^lo;y9!om_M>74TRlw@1N!5Z9d^wVKk%u zd_2y`1jjlwn+?rhi~#zMV|xFw`LqxPj&}43P7*n4_C-sw@f7)&{sIKVWpLd16 zVDc4~eFMQe>p*n(x2q0sbF2&I8#@+IVLdaL5*~iCerbE6UR2#_7HRjdSUhnk2p4&^ zY-~=BaB~0_m!HXFi{@B#By=fImK5G?Gp#Kb>{%zE-e*kpoQ9iM!xo57k+^O>l*)A&Ic2>&dykJLCZk3q$Pmlh~qxAikSLnTXh4c<9Ig&^^ zX^xdUrUtU2_?rs)*NqwW@86%x8=)3m?75wUfJSTk_Uhd&edc7Q#B z=%_UH*}?|u7caBchYBvO=LB-w5nzG!1Q~v-;H(}7{8_}jhp-RsH|@JNi1 zpsu}rKtdw@w7wkfR^Zl1jT6H@as{fgvu)76cCwr&1%MgjT( zqHojd;{wZz^{6Q56d=vg-uH3g{Z~>(D!K={R9(1Qh9#5DpuQ-*Qt_v2nfIaLL>rxZ ze?x6onOivxjIC`-woy@2Vh7JG7Amwv--9RKzn^f1TOj{VJZE!w$8BM0smwuB6TFr4 zIa3Z0S7VGcIn(NQ4K>lu_QDT~oV{f&CG3|UT-kg0R1VO;@EFpKqAy*4Ub5XytHtTr z+ZQ}%VE_1n`jcbp_LzM-VVb~LNHuBRBD1YSD;obU@}I3mD@MEFX`dEa#ag5Y?UTI0 ze0j1Djv~Rj6tj>HbYZc;)iY-fdrjA@ltd20f_fbgkmWY)0rP~Qy~V}FhWx>;IX8UL zUQSO9m{Jk)&$@;Nq}^K{dcrsb#8oJhc}*avex>(TzD4~Bi+lGvaALxw zzjjSbim0Q26k_Mz3KT516~(e0`hL|Aq_~_6(SvRbnt=meI^J{9Q?}_?-CJ@D>>?Tm zrcx^J(M&4;tk@FQdVDhSt>Zjg!aP=H?)dCq;S@34+8Y?GlbW+bp@V275hG|Hw^CBx zU5d>BGU+%62ceR>lHF7MFtIbsC>0vMNk~tkyRIXME!Vt840;8qcy`bvA_Z`9X$fJT z4g5qIkRz~U8syx5jI?}Sz)AO%IH&4mo7$rsL{j)Rq4#XSmX>>Y&kt6bIb}0M#KmQA zZhH1($tIM$9_y2-x`u}QFRDI3fl)_rDsmL(YdNVDsk!fOnC-@!VxW&OBzhDeMHe|d z@*Gea;4mBdWdGX+fZdSi6?!f%`AQjKf6QsE;@1@wZGn|ypf5S3m@{3Es=P0DngIz1 zx}7Qz57Ap+lO517e3=NEIL2*~ncf;^7SqvFxvAP*xw+SC`Ad|P+b>`Ky82~{pG@|> zD&8ZIUW6=Z=M~Z`N8NT}V3O1dgEQad;xhiaUWt~KUvE(9fTs)ri?_A41>^Av z`T1g7H9ng!fYs4-6wE+X{JBat#brU#yi1?vzfwjZQhGevbO@KDAwhT-IE^ZU7=^SR&J8+I|u!~-%w=)9jlf4+Y83c;__S?psrKfgX(-&|^jf}IE| z!tvwB-D8}fk#tAmjGv!h%w<9YA(Vhf&u88qibT$>AE&yJ_XAMJ31+y;%t7H6fZn#| zExR_v%iX(oU1qw3vy4mH{_+0<&^Oa;^B1#}>r6NV7W^{bhvvOSDXJ8&f3yg>Wg3Zb zgtW$e0;=bfaH?CyKli&oeP7&lZabz*6XG8ZSChJrZt=K&RV_W)r@ld%gS;E!SB4pU zwei-EU1+wxQFpEONqWUC2SOEzsBgAM`YSMusin~0cj{#nrYMg$pVB53@hBIs2{Ak{ zNzPt$>xx?J2Em1AwXwCj?_mwQ$Ha#hV!#_>+MsblQ25p_=E{!}zxV|ORpr)O1D7jl z8^a7-HnTyS{!$A#eOM|@6U@}+^smj3Ben~0aKvueeP=?cq(0?ORLsHxR-+xTqqN>Ms0E8v!Mt8pN_ zUcOG!(N9ZRPk;rORH{PrTz0)I?$EZXqwS@+2S>9-l#*M2MWvUeSiAPYQ0zxU4#PLa zqV1ahYH23GVH!?k;tR6gJx5K2HLoKu6!mZE`O?=t5ap+9vi{w$oEW zspQVcNo)Z&Y_Tt{5r3>NG4=J~8PZ%OF-tU6%3K}6f+eFy;uubKW;K^fr6ed)9jp{&Qyr1*h6h&u>{C-n*>N?l!D;ZNJp z%lFc;W6aDoMGkcPj#U5f1;*D47$QVmjxm1lAcy|Y$#~GV1kiACem0WOBh>@;D2^Y{997oJXS$NX1~ z{e}4dU6ua-#Q6Vbn*OiZ0sdciB6+pcyr$TRH`J{S0TB@`eG_cR*Q%=H|NL_oVLX+s@f#zBZw{FuRphdWLB@i663$X+wY{QC_YF(O{ns}@5yU3};fg!4eULX7E0$OWcOdVL7hRBS zfk8ZrLGJZ`@p0rs+m9f}sKhMWkYr1=f5FZ}~Xo6C} zcp*?xCMK-^4i;SCgbt)UZ%Um&_Q&GlTgW72`i8uF7vJkprpm;`bbx`Oz$)<#v`L!R zu01`c9-E11soR8do^*XqW+pz+kZ@2j)O@TO$RWz%M6P zsp@A8%=GKPz#{*X2N@W20VP>BMceT88p7C@P7}(GadJA+J4a3f?AHZ=36$52L4a-Z zu(m*t01BlKV)v&{pBj9zFiq9|EardL7+gL3+bjA4h|Dk+u*fd$Y7Kl3f>97o3 znJN2%oqx6*v=0x>EpV)yT?jiy?K-OS6Tum)Qjoh z#{?L+evj#_81LO*60W~#&{PAis1yjIe2*2|EK{N(NM3;~Lo8A*A7eeH_>5*hi$EA+ z+K^E3^V^yBifypk{no$TjOZ3{eR(5m^*|c(V4yxB1B2+Jf@T%Q8T#3sLF%$;Q4XaT z5pM`|fEzIQWgkYq?)=`_OQ zw&I$G(o$nZc8ScFFR4A*kq`Xy%hUP?A61MWW|z8GyOavy?i?J6F9`T|~|oy1ELl4enJ|%~(j)h#kOvY0RcI;X2YcL=*zIpZ!== z9aa9@FS%(v&W8YfR>l`>UZkxfyobV{B0XQ;9{taQwZ%~nSYbMvn!w=}V8ysz-qO_k zP{Zld7u%oo@#A?!kX&gR2Qjj3!oyS@y`n>0y6iY0gA>j+Z1aKF!3J~Bm`!u6?BK8g zV;fWH83cUae9f5um`EqV_Wo9?kt0zTvLmuUBgR-XHrA)J8||w=3~HQxd-xICvdj0;g7wPXLvu;_jjavAhwLSlX zXLVi9DVsFf2#r<-bks(Q%7-8qffSS@=~Sn!qZ5oM8ZhuiQ%_&Kmx>q3 zWVLl%`1PHEJ`0J-TJ{#vlf7g!A7VtOroZ=Z3w8o>_Y2i*pfnTQWP^f&R)8CN#NY~P zc;e>^PJ$Z;^z#M`Cog{1>I_0{t&Rk#^GitRJMCphFY&5ICIRGD#ydGXdmUI7=e~UT z7G6hJ*P?kb9qNG7-Y4Zmi5Rt1$(1;(oMfx!*s(%k<+@21_@g{`MFm2GI}M3L8mKNz4yMcW%TS_8fB@!4JM92e;v!zY!O%V64y z9}YLSnMyZh#6_x+ZwJtxcFhidhtO)X>+`!db+h_OeY+f<|#TcZcDjAep-@_LyGr$q#o-&78R6>04^D1$vNg7`^ z51|@Mue6b$LVNxCb#Q3tIrrUzrtYK1os}Daj)~jp)u^ehZLYqUo|N=0+Nr}R9uc?j z($>Eu6Y4qcv&ecDm(0#a&YX{p6GuFxBjPEeoI0>tVFi;=H4XDAtn@@&tgX=qr>N1$W*)3@ z{rWS6AWNx)YaS~_BL%N<`N}VM*+C$6RARXiwPmc(YN$564iZ_*Aq@}2lZD1CD$tPI zM1qbR^dW>?!6&UulcT;AzIBC6T3f&TEVlgwtHq(}{73Z7SME3~1r@x2zB!_|I@q_d zb)bekF#Dg0#hnzn_wxB6mb>b6n_v($uRbp<+#2m#e=9I8KVJt4%K+Njj;{0jn@@*Z z#ed|ho;cM^uAubTHO_+DG^4nUZEdK)5s z3c0ztluSkUSamMz@kx-2GD>E%0*$2n>NvMn{|pBjDT=M*OoSYv7|&oBq+BN6mCQ<} z*DrYuNk*!0wEto$bQ~l^N1qxU8L&7GKKX+P6r#`f>Z?@@rlg1D=;e!q|dcV1_UUg{wiFe)SN6Qr!2Q;Yrx;I9_# zG5z~z)jE)~!66|QYJy}<|Dl&feES&^6$3u;?m1>oO#7hlVyfFJu0Y2BJvNqWM#`_1 z5$F(MB_phbV8o?tasO7lB3-IvU41>ImJ8j`S>!(hk58rq>l9RX$X*9#pbZHXuk`K{wL|g`;G~M)O z3M6~_!>2xiE)XSQj#hHC?=LMF2v~f3*PmO&Pkz83SRbdra-02$iT;=F86BKKj8tdO zo@MnsseQPx8e)+T=iWTd zLxK$HYl$=j$vDTLFn|up2T|p>`G!%|9!7AxB^A_lyIS-0tIakC0V>=P5R5_NI{P?e z9_8?;&uPZcH-DPVN*@2x@^@|?3#G{ZTee!5WKu8qKVW<-;!<}dOzP)^prg#L3)$t1 zuhB+GNoKjSPyC-xc|PQ~Kp-uG?XX8d-N z(VXT`x#em!OId|tNx@WaD&4IjQ*1x zvxdDsZUgSAB^-v9mMOr5^I*U_SD1Nb3>Eu@2&-Jh)EshO<9oUH@86H*KLZlObg%|2 zGGa=B2w;!3PXQWFKu&HD%&dSoRl_fSiJf5v#y&vSfAuO<0ud3B1}2?ZFHh=y^|{Zt zAR;?HAB+1>ZR+%Ytcgb~-zugMnlWJ3X%84!9INGx6MM1)mN!7nqK1_WIEWaIa=+Hg zx%k6-MIvss6F@^UV0{e!98^+?fk#!RFx!lL9ov@xh*evl{y^x@$&6b0dJy%;-X$b= zjP!Wd-Wh`wXoWDQDb~wgQjB=?kO0`7E;w(6d3tH|E%o_bG8w`C&WwN;T zj@y|%5$RNBx^hjY=eu&NPU((Lf}i2jO*1R~TeqwuT-7;dL5b>&Ix!!jZ;JF-Bf=vn zetu=J#)ffkQCBM+FKQH7x1JR=Ex(0#S?za=+Hoe4n9}v)WMQ?oIy#w?_NT_QIr&3= zeuoI@9zZZ?%mV0lNdi6H?zP&Vnvif-#lzg|_r@U26ai`-pBKdS^cj}pG~riQ5w5pk zNd}BkTa)TO_^O{84h{s1W07*-YHFf(R07JF?ar$){gweJyWexEF*F~NXuXuCD}rz( zzke_R%nUS00uUd#(Aqq2Xpx|Go4J0odR$*Svd2mtgTVl8q7T!$Hn7kUTf-}t&uc`J zZ~=qDP~GQMIb>kjZPu43BW1lxxoyBX)>H1D7eVGza@m82)uaSvTN{>>g<&?p1H_Tk zlW())XB>;{#6T}Kh=$tyLQFP*CbH#)N>Yy*|7{309r<%XI`NQaG3)c8ZH>8_Z&ZQ} z*||(GrP=NkZ=9zVa_hkn5our?Q;E%O>W!mqTB&$wYQ6`gCJR$yxE>F+3)&^qC&o^z zUqEBr@Ytdt7B+C4g?@4soRw?FmJGO$v)5020$3fH*xhrNNa*V`cIFItf31;a)Ma@> z(>VZzG?Mp4Zs$WgH99(KFa=E(qD-(!INTRG9|RH>kdd(PrFM)P!pVPl(b|Sd6Bc}kt z2#N(=jBwkNh9I1l_a|HTe0GEt=kj|<-p|;i1r{cD`{}<2-bw%8_+xf%C|*F++%)>T zuL#~Z#P#24<$pg+_x}@Gb9-~b-+TK1+8yoFfUQpY0JIU&7hDF-6?Y$+`hFI@H&$ug z=SZ=Izx-mZ>FB=@DqenfX(W)S^3a}wu$XJL#tnp?C%Rb)v{vHzA$_4R74Uo_ohv${ z8;qrmr>|;nwwhXXw$@0PS0BP?sj0n1DBJEGHD=@OapM+Xz#ChcTf3pG{E7KGuf?(f zSVqtp*Eg|Vm%f>8uzdV_j#~v^chD_8^L7ThO#38d*uwH+6Q?3=lVNE^hLaFA*7YVK z!oM=@D8fG>bKooFe}(zM9`~^bD=|2wpmA|dqeG{M?L4K&NQq-`raN!cse~KjvsP~g zmr=_(o+l6+HU=S{&ipPmAay*7Yfxl`2G72kX3P)S}YS15*lWixD;L26TDZ1xGn92#h46?Dmy713zuoJm<9e(CcfN_d<~} zzA5f}$E3PMPhMFjDz;L6!&}%C`CB6)Yt`(a4H+8lK+Ds!9Q{`*@l}XFSnO9$U7yA$ zLG{QoEU1f}b(`%s#Z7-4juNqW&2sAzXeYDXJ2DE!^D<(Y&B}q2Q&v~cUPag_;>Tv0 z)LtOTvf;27)=X6Gveh3kyY}B);W);Hks=P)c@Gw*!p1f!7M0A>Dbq^z53g^wFq_ThO{dv6(_|3fDEh zi;Bt!Gxfj#@#ry=TLV0;EvhX!pR)ec?GD+@V^A+FEPW)~ZnpdHT2tNLZ^jKZAHZ@b zHxUeqlo^2&>PkV zZ^+viEig4B{6FZ#;LUQGROYz=wthQh@*k0ddKWm7)Me zfkDHZ$W{YU^A7Sl6h8Qkd95fIajp*61nNU$i4O{DT<+-5X$%*x(&~H{9&WAvDO}E{ z0!of$cO}wlvdVdvuSHVGzYBczPBHO%{6U`-Jy z`QNLnr~2Inb#L5wi8#rD{Q?%q#NFN9FyHz;(ESkS%K$QwO@q{bPN;+liJMjBWiuc# zs-lv|izBtj76oT-jn}W;zo9+;eQ36H0Bj~rG16ACMbCREGDToY4S37C!q?bp2}{y4>~mBu~3|Bbemb#>T5&xGVYO11Mt8zuTspL;OAZjB%sObxW(3Eet3 zj*USzk>Aauv~om^$JLKb{gG}?gXEL!o&A_gY@(2sAzM|QrgZD+2#2JeX|V#Y>>rxT z&yh1K?p5z1XbDvF)F^Da8uN5irN#MsUAI;^~cl2ybxmwWP0_4uZ2Y2(s-grRa7?v>^i4$D$lB#pweGfgzE4`0HuK=3+g4V52n(;tH(p z_g!(eL%14*c?J_tq}ON`XUFS#_AFB9APrd-@qCDld3my(2Ln4XI>DmdSgwXnup9dP zm=$OcZa$#o9zJ{+dGU@Pm~**T*LNZ-Ao*?kWCy^aV_={3UJFWs_8T15)&TH8i3yd$ zi(#j9zSq|Q@x1+{-zD|V{y0>m2Q(V*N8PZLZ}SJsl=F7j@rgh1Q@+5&+C)R zq!!q~iUz1ag28AtXmxTThIaS42bYh5pkt6{Y10@f9tk;+XVA>BjWG*3*HU#M^dgxC zdHFycl^6?QwC>-}M5qEIK>=91Y!1cp_~lk5&h9+vn@THjA4874!Qri_QxC`i@;Mg)eJw}gBk zZ||2wuz6v2(c5tr+iAMd-Ri+`>dg2LmHFO0pUPt+b~obg3?E~4g`cgT(T9s9W}s9yc7kc1;I zKTLoOXH8$y(NLMIaP_L#!(ZP#e8`9j08^D49Fhc*xnb<|I`iBEzp z$VOAfK3gke%$0~{wmR1jJX&jQ5HHVa*bg?x@>VTgMDX|s@1BGMAVP-hpP^lVU64Qx zU5u6K6%Z1-)LUO)uMG(t2_>7hWE=O*tjx@YK3Ujy#={t61T7kprP@?bd}wsP2&tzb zqLb~8W93$1$rf?ms~OL0v<8GwGalKt0U0PVaO~#fC5yUaOVyr`WW51AAZDgOnZ=h* zPEKMDeV1{^gHW@WHYgA2P|TMWfV3BYbq692B5M75#mh=u7|sY?eq#906YoOO)t>}^ zbt}qeO{JE7x$Bd~o4HDw_Y#?`x`zqbG=WH3Uc-Y?nVA>rWj$m~y;gMd`am6aNF%dW zP|WO_Qd7sG!%_&-Mh+$4O~0d%5FB-X8=x5vSsP#0&mw9GuKE5%#oGuF($Ug73pCH$ z0elh&6$nAh!^7hNzqo*vL3}}yl9HuhBl{PLz;>G(-ky~k!%}vW-t_!rK}P~sHxCBk@tDaFRUtt^=CV-bVM*Sn z3d&)pIuv)g!*8{q33fLH`BBh4HgbL7A#5)(s>4nFm}f`gxFrHRb%k8wJPreUHv*3W z6$387GQK{jeNwFQSXgMNEwsGIhPW?J_97D8QvD<7&PJcdkB5x_Ow1!9K*-7pXRlI7 zfs+80ZeLhr)y!ev?_u*g^4+_4m)5YOusH&;{6hbpC%F4L1g9RdRSI$$#C|eZ<09Ji z+^Mh4@NoGJQKbF*S5jR?6!!%O2V3_R+EZpul!1M;Vb&_ntJAX)4g%?L622aRBY7(^ zDe0Plfx*}?*t`K`MIuhTTCX5)NZJ(JtWLbQbrxVoq|aTqCZ+50^YLlgOeV7xs0usa z$45r;AK9L5P8)>4p9=6WC~-=L-XUu1cZcSdm35Q*YiLC_ID0?887pIe!Gxul9u$K(G_3F}8S(ZJGAY%8-T}O`A>5#MmVcp!B>|Vi zCn(r>AqZI5w{*+ojebtD0dB_1szqY1ZKJUKfz=c3eXK&k9NGq7^kS$ki4m@z!FpO|JJ*61(gVN4y)YCRV%0Am8qqAW>iXzWlqLw{K;q4rja`h@#}$Q{e80Q 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%0Ds5+keJj8w`a$1TxcHC1MHVPO$ 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ⅇX`6^8bs$GQg4T&S4+0b2uThytu=whbvACQRQzh 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 literal 0 HcmV?d00001 diff --git a/assets/codex.png b/assets/codex.png new file mode 100644 index 0000000000000000000000000000000000000000..5fa12fa590a06b3939e17183599850dd266a0e89 GIT binary patch literal 133586 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%0AMe&q}~$7 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

#mnEmbKD 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 F{{aK5DUARC literal 0 HcmV?d00001 diff --git a/assets/how-it-works.mmd b/assets/how-it-works.mmd new file mode 100644 index 0000000..c3572c1 --- /dev/null +++ b/assets/how-it-works.mmd @@ -0,0 +1,57 @@ +%%{init: {"theme": "base", "themeVariables": {"fontFamily": "Inter, ui-sans-serif, system-ui, Segoe UI, Arial", "background": "transparent", "primaryTextColor": "#172033", "lineColor": "#718096"}}}%% +flowchart LR + claudeClient(["Claude Code
CLI / VS Code / JetBrains / Bots"]) + codexClient(["Codex CLI
VS Code extension"]) + messagesApi["Anthropic API
/v1/messages, /v1/models"] + responsesApi["OpenAI Responses API
/v1/responses"] + + subgraph proxy["Free Claude Code Proxy :8082"] + admin["Local Admin UI
keys, models, status"] + config[("Managed Config
~/.fcc/.env")] + convert["Responses Converter
OpenAI to Anthropic"] + router{"Model Router
Opus / Sonnet / Haiku"} + optimize["Request Optimizations
cheap local probes"] + normalize["Protocol Normalizer
streaming, tools, thinking"] + adapters["Provider Adapters
Anthropic + OpenAI-compatible"] + end + + subgraph hosted["Hosted Providers"] + nim(["NVIDIA NIM"]) + kimi(["Kimi"]) + wafer(["Wafer"]) + openrouter(["OpenRouter"]) + deepseek(["DeepSeek"]) + end + + subgraph local["Local Providers"] + lmstudio(["LM Studio"]) + llamacpp(["llama.cpp"]) + ollama(["Ollama"]) + end + + claudeClient -->|"Anthropic Messages"| messagesApi + codexClient -->|"OpenAI Responses"| responsesApi + messagesApi --> router + responsesApi --> convert + convert --> router + admin --> config + config --> router + router --> optimize + optimize --> normalize + normalize --> adapters + adapters --> hosted + adapters --> local + + classDef client fill:#efe7ff,stroke:#7c3aed,stroke-width:2px,color:#2e1065; + classDef api fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#172554; + classDef proxy fill:#ecfeff,stroke:#0891b2,stroke-width:2px,color:#164e63; + classDef config fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#78350f; + classDef transform fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#14532d; + classDef provider fill:#fff7ed,stroke:#ea580c,stroke-width:2px,color:#7c2d12; + + class claudeClient,codexClient client; + class messagesApi,responsesApi api; + class admin,router proxy; + class config config; + class convert,optimize,normalize,adapters transform; + class nim,kimi,wafer,openrouter,deepseek,lmstudio,llamacpp,ollama provider; diff --git a/assets/how-it-works.svg b/assets/how-it-works.svg new file mode 100644 index 0000000..6e2a940 --- /dev/null +++ b/assets/how-it-works.svg @@ -0,0 +1 @@ +

Free Claude Code Proxy :8082

Anthropic Messages

OpenAI Responses

Local Providers

LM Studio

llama.cpp

Ollama

Hosted Providers

NVIDIA NIM

Kimi

Wafer

OpenRouter

DeepSeek

Claude Code
CLI / VS Code / JetBrains / Bots

Codex CLI
VS Code extension

Anthropic API
/v1/messages, /v1/models

OpenAI Responses API
/v1/responses

Local Admin UI
keys, models, status

Managed Config
~/.fcc/.env

Responses Converter
OpenAI to Anthropic

Model Router
Opus / Sonnet / Haiku

Request Optimizations
cheap local probes

Protocol Normalizer
streaming, tools, thinking

Provider Adapters
Anthropic + OpenAI-compatible

\ No newline at end of file diff --git a/assets/pic.png b/assets/pic.png new file mode 100644 index 0000000000000000000000000000000000000000..52bef086fbb67fb1894778ba916622d05a9bf30d GIT binary patch literal 136351 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

~@;A1@C=r4YG{pa(NMs!HY^7Mh`55S(^J0Tcr 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 literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4eadc12 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,131 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "free-claude-code" +version = "4.0.0" +description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" +readme = "README.md" +requires-python = ">=3.14.0" +dependencies = [ + "fastapi[standard]>=0.139.0", + "uvicorn>=0.50.0", + "httpx[socks]>=0.28.1", + "markdown-it-py>=4.2.0", + "pydantic>=2.13.4", + "python-dotenv>=1.2.2", + "tiktoken>=0.13.0", + "python-telegram-bot>=22.8", + "discord.py>=2.7.1", + "pydantic-settings>=2.14.2", + "openai>=2.44.0", + "loguru>=0.7.0", + "aiohttp>=3.14.1", + "jsonschema>=4.25.0", +] + +[project.scripts] +fcc-server = "free_claude_code.cli.entrypoints:serve" +free-claude-code = "free_claude_code.cli.entrypoints:serve" +fcc-init = "free_claude_code.cli.entrypoints:init" +fcc-claude = "free_claude_code.cli.launchers.claude:launch" +fcc-codex = "free_claude_code.cli.launchers.codex:launch" +fcc-pi = "free_claude_code.cli.launchers.pi:launch" + +[project.optional-dependencies] +voice = [ + "grpcio>=1.81.1", + "grpcio-tools>=1.81.1", + "nvidia-riva-client>=2.26.0", +] +voice_local = [ + "torch>=2.12.1", + "transformers>=5.13.0", + "accelerate>=1.14.0", + "librosa>=0.10.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/free_claude_code"] + +[tool.hatch.build.targets.wheel.force-include] +".env.example" = "free_claude_code/config/env.example" + +[tool.uv] +required-version = ">=0.11.0" + +[tool.uv.sources] +torch = { index = "pytorch-cu130" } + +[[tool.uv.index]] +name = "pytorch-cu130" +url = "https://download.pytorch.org/whl/cu130" +explicit = true + +[dependency-groups] +dev = [ + "pytest>=9.1.1", + "pytest-asyncio>=1.4.0", + "pytest-cov>=7.1.0", + "ty>=0.0.56", + "ruff>=0.15.20", + "pytest-xdist>=3.8.0", +] + +[tool.ruff] +target-version = "py314" +line-length = 88 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes (undefined names, unused imports) + "I", # isort (import ordering) + "UP", # pyupgrade (modernise syntax for target Python version) + "B", # flake8-bugbear (common bugs and anti-patterns) + "C4", # flake8-comprehensions (idiomatic comprehensions) + "SIM", # flake8-simplify (simplifiable code patterns) + "PERF", # Perflint (performance anti-patterns) + "RUF", # Ruff-specific rules +] +ignore = [ + "E501", # line too long — enforced by the formatter instead + "B008", # FastAPI Depends() in argument defaults is intentional + "RUF006", # fire-and-forget tasks intentionally not awaited +] + +[tool.ruff.lint.isort] +known-first-party = ["free_claude_code", "smoke"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "auto" +skip-magic-trailing-comma = false + +[tool.pytest.ini_options] +pythonpath = ["src"] +addopts = "-n auto" +testpaths = ["tests"] +markers = [ + "live: opt-in local smoke tests that can touch real services", + "interactive: smoke tests requiring manual user interaction", + "provider: live provider checks", + "messaging: live messaging platform checks", + "cli: CLI integration checks", + "clients: client compatibility checks", + "voice: voice transcription checks", + "contract: deterministic feature contract checks", + "smoke_target(name): route a smoke test behind FCC_SMOKE_TARGETS", + "xdist_group(name): group smoke provider items under pytest-xdist --dist=loadgroup", +] + +[tool.ty.environment] +python-version = "3.14" + +[tool.ty.analysis] +# Optional voice_local extra: torch, transformers, librosa for local whisper transcription +# Optional voice extra: nvidia-riva-client for nvidia_nim transcription provider +allowed-unresolved-imports = ["torch", "transformers", "librosa", "riva.client"] diff --git a/scripts/ci.ps1 b/scripts/ci.ps1 new file mode 100644 index 0000000..f7412b8 --- /dev/null +++ b/scripts/ci.ps1 @@ -0,0 +1,210 @@ +param( + [string[]] $Only = @(), + [string[]] $Skip = @(), + [switch] $DryRun, + [switch] $Help, + [Parameter(ValueFromRemainingArguments = $true)] + [object[]] $RemainingArgs = @() +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$CheckOrder = @( + "suppressions", + "ruff-format", + "ruff-check", + "ty", + "pytest" +) + +function Show-Usage { + @" +Usage: ci.ps1 [options] + +Runs the local sequence for the same check IDs enforced by GitHub CI. +Requires uv on PATH when running ruff, ty, or pytest checks. +Local ruff checks repair formatting and autofixable lint before later checks. + +Checks (in order): + suppressions Ban type ignores and legacy future annotations + ruff-format uv run ruff format + ruff-check uv run ruff check --fix + ty uv run ty check + pytest uv run pytest -v --tb=short + +Options: + -Only ID Run only the given check (repeatable) + -Skip ID Skip the given check (repeatable) + -DryRun Print commands without running them. + -Help Show this help text. +"@ +} + +function Write-Step { + param([string] $Message) + + Write-Host "" + Write-Host "==> $Message" +} + +function Format-Argument { + param([string] $Value) + + if ($Value -match '^[A-Za-z0-9_./:@%+=,\[\]-]+$') { + return $Value + } + + return "'" + ($Value -replace "'", "''") + "'" +} + +function Invoke-CiCommand { + param( + [string] $FilePath, + [string[]] $Arguments = @() + ) + + $parts = @($FilePath) + $Arguments + $commandText = ($parts | ForEach-Object { Format-Argument ([string] $_) }) -join " " + Write-Host "+ $commandText" + + if (-not $DryRun) { + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $commandText" + } + } +} + +function Test-ValidCheckId { + param([string] $CheckId) + + return $CheckOrder -contains $CheckId +} + +function Assert-ValidCheckId { + param([string] $CheckId) + + if (-not (Test-ValidCheckId $CheckId)) { + throw "unknown check id: $CheckId (expected one of: $($CheckOrder -join ', '))" + } +} + +function Test-ShouldRunCheck { + param([string] $CheckId) + + if ($Only.Count -gt 0 -and ($Only -notcontains $CheckId)) { + return $false + } + + if ($Skip -contains $CheckId) { + return $false + } + + return $true +} + +function Assert-UvAvailable { + if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + throw "uv is required but was not found on PATH. Install uv first (see README or scripts/install.ps1)." + } +} + +function Test-SelectedChecksNeedUv { + if ($DryRun) { + return $false + } + + foreach ($checkId in $CheckOrder) { + if ((Test-ShouldRunCheck $checkId) -and $checkId -ne "suppressions") { + return $true + } + } + + return $false +} + +function Invoke-SuppressionsCheck { + Write-Step "Ban suppressions and legacy annotations" + $pattern = '# type: ignore|# ty: ignore|from __future__ import annotations' + Write-Host "+ Get-ChildItem -Recurse -Filter *.py (excluding .venv, .git) | Select-String '$pattern'" + + if (-not $DryRun) { + $matches = Get-ChildItem -Path . -Recurse -Filter *.py -File | + Where-Object { + $full = $_.FullName + $full -notmatch '[\\/]\.venv[\\/]' -and + $full -notmatch '[\\/]\.git[\\/]' + } | + Select-String -Pattern $pattern + + if ($matches) { + $matches | ForEach-Object { Write-Host $_.Line } + throw "type: ignore / ty: ignore comments and legacy future annotations are not allowed. Fix the underlying type/import issue instead." + } + } +} + +function Invoke-RuffFormatCheck { + Write-Step "ruff format" + Invoke-CiCommand -FilePath "uv" -Arguments @("run", "ruff", "format") +} + +function Invoke-RuffLintCheck { + Write-Step "ruff check --fix" + Invoke-CiCommand -FilePath "uv" -Arguments @("run", "ruff", "check", "--fix") +} + +function Invoke-TyCheck { + Write-Step "ty check" + Invoke-CiCommand -FilePath "uv" -Arguments @("run", "ty", "check") +} + +function Invoke-PytestCheck { + Write-Step "pytest" + Invoke-CiCommand -FilePath "uv" -Arguments @("run", "pytest", "-v", "--tb=short") +} + +function Invoke-Check { + param([string] $CheckId) + + switch ($CheckId) { + "suppressions" { Invoke-SuppressionsCheck } + "ruff-format" { Invoke-RuffFormatCheck } + "ruff-check" { Invoke-RuffLintCheck } + "ty" { Invoke-TyCheck } + "pytest" { Invoke-PytestCheck } + default { throw "unknown check id: $CheckId" } + } +} + +if ($Help) { + Show-Usage + return +} + +if ($RemainingArgs.Count -gt 0) { + Show-Usage + throw "Unknown option: $($RemainingArgs -join ' ')" +} + +foreach ($checkId in $Only) { + Assert-ValidCheckId $checkId +} + +foreach ($checkId in $Skip) { + Assert-ValidCheckId $checkId +} + +if (Test-SelectedChecksNeedUv) { + Assert-UvAvailable +} + +foreach ($checkId in $CheckOrder) { + if (Test-ShouldRunCheck $checkId) { + Invoke-Check $checkId + } +} + +Write-Host "" +Write-Host "All selected CI checks passed." diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..81021e0 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,212 @@ +#!/bin/sh +set -eu + +CHECK_ORDER="suppressions ruff-format ruff-check ty pytest" + +dry_run=0 +only_checks="" +skip_checks="" + +show_usage() { + cat <<'USAGE' +Usage: ci.sh [options] + +Runs the local sequence for the same check IDs enforced by GitHub CI. +Requires uv on PATH when running ruff, ty, or pytest checks. +Local ruff checks repair formatting and autofixable lint before later checks. + +Checks (in order): + suppressions Ban type ignores and legacy future annotations + ruff-format uv run ruff format + ruff-check uv run ruff check --fix + ty uv run ty check + pytest uv run pytest -v --tb=short + +Options: + --only ID Run only the given check (repeatable) + --skip ID Skip the given check (repeatable) + --dry-run Print commands without running them. + --help Show this help text. +USAGE +} + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +step() { + printf '\n==> %s\n' "$1" +} + +quote_arg() { + case "$1" in + *[!A-Za-z0-9_./:@%+=,-]*|"") + escaped=$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g') + printf '"%s"' "$escaped" + ;; + *) + printf '%s' "$1" + ;; + esac +} + +print_command() { + printf '+' + for arg in "$@"; do + printf ' ' + quote_arg "$arg" + done + printf '\n' +} + +run() { + print_command "$@" + if [ "$dry_run" -eq 0 ]; then + "$@" + fi +} + +valid_check_id() { + case "$1" in + suppressions | ruff-format | ruff-check | ty | pytest) return 0 ;; + *) return 1 ;; + esac +} + +validate_check_id() { + if ! valid_check_id "$1"; then + fail "unknown check id: $1 (expected one of: $CHECK_ORDER)" + fi +} + +contains_check_id() { + list=$1 + id=$2 + case " $list " in + *" $id "*) return 0 ;; + *) return 1 ;; + esac +} + +should_run_check() { + check_id=$1 + + if [ -n "$only_checks" ] && ! contains_check_id "$only_checks" "$check_id"; then + return 1 + fi + + if contains_check_id "$skip_checks" "$check_id"; then + return 1 + fi + + return 0 +} + +assert_uv_available() { + if ! command -v uv >/dev/null 2>&1; then + fail "uv is required but was not found on PATH. Install uv first (see README or scripts/install.sh)." + fi +} + +selected_checks_need_uv() { + if [ "$dry_run" -ne 0 ]; then + return 1 + fi + + for check_id in $CHECK_ORDER; do + if should_run_check "$check_id" && [ "$check_id" != "suppressions" ]; then + return 0 + fi + done + + return 1 +} + +run_suppressions() { + step "Ban suppressions and legacy annotations" + pattern='# type: ignore|# ty: ignore|from __future__ import annotations' + print_command grep -rE "$pattern" --include='*.py' . \ + --exclude-dir=.venv --exclude-dir=.git + if [ "$dry_run" -eq 0 ]; then + if grep -rE "$pattern" --include='*.py' . \ + --exclude-dir=.venv --exclude-dir=.git; then + fail "type: ignore / ty: ignore comments and legacy future annotations are not allowed. Fix the underlying type/import issue instead." + fi + fi +} + +run_ruff_format() { + step "ruff format" + run uv run ruff format +} + +run_ruff_check() { + step "ruff check --fix" + run uv run ruff check --fix +} + +run_ty() { + step "ty check" + run uv run ty check +} + +run_pytest() { + step "pytest" + run uv run pytest -v --tb=short +} + +run_check() { + case "$1" in + suppressions) run_suppressions ;; + ruff-format) run_ruff_format ;; + ruff-check) run_ruff_check ;; + ty) run_ty ;; + pytest) run_pytest ;; + *) fail "unknown check id: $1" ;; + esac +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --only) + shift + [ "$#" -gt 0 ] || fail "--only requires a check id." + validate_check_id "$1" + only_checks="${only_checks} $1" + ;; + --skip) + shift + [ "$#" -gt 0 ] || fail "--skip requires a check id." + validate_check_id "$1" + skip_checks="${skip_checks} $1" + ;; + --dry-run) + dry_run=1 + ;; + --help|-h) + show_usage + exit 0 + ;; + *) + show_usage >&2 + fail "unknown option: $1" + ;; + esac + shift + done +} + +parse_args "$@" +if selected_checks_need_uv; then + assert_uv_available +fi + +for check_id in $CHECK_ORDER; do + if should_run_check "$check_id"; then + run_check "$check_id" + fi +done + +printf '\nAll selected CI checks passed.\n' diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..6f73b1b --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,557 @@ +param( + [switch] $VoiceNim, + [switch] $VoiceLocal, + [switch] $VoiceAll, + [string] $TorchBackend = "", + [switch] $DryRun, + [switch] $Help, + [Parameter(ValueFromRemainingArguments = $true)] + [object[]] $RemainingArgs = @() +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$RepoArchiveUrl = "https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" +$PythonVersion = "3.14.0" +$MinUvVersion = "0.11.0" +$ClaudeInstallUrl = "https://claude.ai/install.ps1" +$CodexInstallUrl = "https://chatgpt.com/codex/install.ps1" +$PiInstallUrl = "https://pi.dev/install.ps1" +$UvInstallUrl = "https://astral.sh/uv/install.ps1" + +function Show-Usage { + @" +Usage: install.ps1 [options] + +Installs Claude Code, Codex, and Pi if missing, ensures a compatible uv, and installs or updates Free Claude Code. + +Options: + -VoiceNim Install NVIDIA NIM voice transcription support. + -VoiceLocal Install local Whisper voice transcription support. + -VoiceAll Install all voice transcription backends. + -TorchBackend VALUE Use a uv PyTorch backend, such as cu130. Requires local voice. + -DryRun Print commands without running them. + -Help Show this help text. +"@ +} + +function Write-Step { + param([string] $Message) + + Write-Host "" + Write-Host "==> $Message" +} + +function Format-Argument { + param([string] $Value) + + if ($Value -match '^[A-Za-z0-9_./:@%+=,\[\]\\-]+$') { + return $Value + } + + return "'" + ($Value -replace "'", "''") + "'" +} + +function Format-Command { + param( + [string] $FilePath, + [string[]] $Arguments = @() + ) + + $parts = @($FilePath) + $Arguments + return ($parts | ForEach-Object { Format-Argument ([string] $_) }) -join " " +} + +function Invoke-NativeCommand { + param( + [string] $FilePath, + [string[]] $Arguments = @() + ) + + $commandText = Format-Command -FilePath $FilePath -Arguments $Arguments + Write-Host "+ $commandText" + if ($DryRun) { + return + } + + $global:LASTEXITCODE = 0 + & $FilePath @Arguments + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + throw "Command failed with exit code ${exitCode}: $commandText" + } +} + +function Invoke-NativeCapture { + param( + [string] $FilePath, + [string[]] $Arguments = @() + ) + + $commandText = Format-Command -FilePath $FilePath -Arguments $Arguments + Write-Host "+ $commandText" + $global:LASTEXITCODE = 0 + $output = & $FilePath @Arguments + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + throw "Command failed with exit code ${exitCode}: $commandText" + } + + return ($output | Out-String).Trim() +} + +function Get-ApplicationCommand { + param([string] $Name) + + $commands = @(Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue) + if ($commands.Count -eq 0) { + return $null + } + + return $commands[0] +} + +function Get-PowerShellExecutable { + param([string] $PowerShellHome = $PSHOME) + + $executableName = if ($PSVersionTable.PSEdition -eq "Core") { + "pwsh.exe" + } + else { + "powershell.exe" + } + $bundledExecutable = Join-Path $PowerShellHome $executableName + if (Test-Path -LiteralPath $bundledExecutable -PathType Leaf) { + return $bundledExecutable + } + + $pathCommand = Get-ApplicationCommand ([IO.Path]::GetFileNameWithoutExtension($executableName)) + if ($pathCommand) { + return $pathCommand.Source + } + + throw "Unable to locate a PowerShell executable for the downloaded installer." +} + +function Add-PathEntry { + param([string] $PathEntry) + + if ([string]::IsNullOrWhiteSpace($PathEntry)) { + return + } + + $separator = [IO.Path]::PathSeparator + $entries = @() + if (-not [string]::IsNullOrEmpty($env:Path)) { + $entries = $env:Path -split [regex]::Escape([string] $separator) + } + + if ($entries -notcontains $PathEntry) { + $env:Path = "$PathEntry$separator$env:Path" + } +} + +function Add-KnownBinDirectories { + if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { + Add-PathEntry (Join-Path $env:USERPROFILE ".local\bin") + } + if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + Add-PathEntry (Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin") + Add-PathEntry (Join-Path $env:LOCALAPPDATA "pi-node\current") + } + if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) { + Add-PathEntry (Join-Path $env:APPDATA "npm") + } +} + +function Add-PiBinDirectories { + if ($DryRun) { + return + } + + Add-KnownBinDirectories + $npm = Get-ApplicationCommand "npm" + if (-not $npm) { + return + } + + $prefix = (& $npm.Source prefix -g 2>$null | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($prefix)) { + $prefix = (& $npm.Source config get prefix 2>$null | Out-String).Trim() + } + if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($prefix)) { + Add-PathEntry $prefix + } +} + +function Invoke-DownloadedPowerShellInstaller { + param( + [string] $Url, + [string] $Name, + [switch] $NonInteractive + ) + + if ($DryRun) { + Write-Host "+ irm $Url -OutFile " + $prefix = if ($NonInteractive) { "CODEX_NON_INTERACTIVE=1 " } else { "" } + Write-Host "+ ${prefix}powershell -NoProfile -ExecutionPolicy Bypass -File " + return + } + + $temporaryScript = Join-Path ([IO.Path]::GetTempPath()) ("fcc-install-" + [guid]::NewGuid().ToString("N") + ".ps1") + try { + Write-Host "+ irm $Url -OutFile $(Format-Argument $temporaryScript)" + Invoke-RestMethod -Uri $Url -OutFile $temporaryScript -ErrorAction Stop + if ((-not (Test-Path -LiteralPath $temporaryScript)) -or ((Get-Item -LiteralPath $temporaryScript).Length -eq 0)) { + throw "The downloaded $Name installer was empty." + } + + $powerShellPath = Get-PowerShellExecutable + + $hadNonInteractive = Test-Path Env:CODEX_NON_INTERACTIVE + $previousNonInteractive = $env:CODEX_NON_INTERACTIVE + try { + if ($NonInteractive) { + $env:CODEX_NON_INTERACTIVE = "1" + } + Invoke-NativeCommand -FilePath $powerShellPath -Arguments @( + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + $temporaryScript + ) + } + finally { + if ($hadNonInteractive) { + $env:CODEX_NON_INTERACTIVE = $previousNonInteractive + } + else { + Remove-Item Env:CODEX_NON_INTERACTIVE -ErrorAction SilentlyContinue + } + } + } + finally { + Remove-Item -LiteralPath $temporaryScript -Force -ErrorAction SilentlyContinue + } +} + +function Confirm-Application { + param( + [string] $CommandName, + [string] $DisplayName + ) + + if ($DryRun) { + Write-Host "+ $CommandName --version" + return + } + + $command = Get-ApplicationCommand $CommandName + if (-not $command) { + throw "$DisplayName was installed, but '$CommandName' is not available on PATH." + } + Invoke-NativeCommand -FilePath $command.Source -Arguments @("--version") +} + +function Test-PiApplication { + param($Command) + + try { + $helpOutput = (& $Command.Source --help 2>$null | Out-String) + } + catch { + return $false + } + return ( + $LASTEXITCODE -eq 0 -and + $helpOutput.Contains("--extension") -and + $helpOutput.Contains("--models") + ) +} + +function Confirm-PiApplication { + if ($DryRun) { + Write-Host "+ pi --help (verify --extension and --models support)" + Write-Host "+ pi --version" + return + } + + $command = Get-ApplicationCommand "pi" + if (-not $command) { + throw "Pi was installed, but 'pi' is not available on PATH." + } + if (-not (Test-PiApplication $command)) { + throw "The 'pi' command at '$($command.Source)' is not a compatible Pi Coding Agent." + } + Invoke-NativeCommand -FilePath $command.Source -Arguments @("--version") +} + +function Ensure-ClaudeCode { + if (Get-ApplicationCommand "claude") { + Write-Host "Claude Code already found on PATH; verifying it." + } + else { + Invoke-DownloadedPowerShellInstaller -Url $ClaudeInstallUrl -Name "Claude Code" + Add-KnownBinDirectories + } + + Confirm-Application -CommandName "claude" -DisplayName "Claude Code" +} + +function Ensure-Codex { + if (Get-ApplicationCommand "codex") { + Write-Host "Codex already found on PATH; verifying it." + } + else { + Invoke-DownloadedPowerShellInstaller -Url $CodexInstallUrl -Name "Codex" -NonInteractive + Add-KnownBinDirectories + } + + Confirm-Application -CommandName "codex" -DisplayName "Codex" +} + +function Ensure-Pi { + $existingPi = Get-ApplicationCommand "pi" + if ($existingPi -and ($DryRun -or (Test-PiApplication $existingPi))) { + Write-Host "Pi already found on PATH; verifying it." + } + else { + if ($existingPi) { + Write-Host "The existing 'pi' command at '$($existingPi.Source)' is not Pi Coding Agent; installing Pi." + } + Invoke-DownloadedPowerShellInstaller -Url $PiInstallUrl -Name "Pi" + Add-PiBinDirectories + } + + Confirm-PiApplication +} + +function Convert-UvVersionOutput { + param([string] $Output) + + if ([string]::IsNullOrWhiteSpace($Output)) { + return "" + } + + if ($Output -match '(?m)(?:^|\s)(?:uv\s+)?(?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z][0-9A-Za-z.-]*)?)\b') { + return $Matches["version"] + } + + return "" +} + +function Get-UvVersion { + param([string] $UvPath) + + $output = Invoke-NativeCapture -FilePath $UvPath -Arguments @("--version") + $version = Convert-UvVersionOutput $output + if ([string]::IsNullOrWhiteSpace($version)) { + throw "uv is present, but 'uv --version' did not return a valid version." + } + + return $version +} + +function Test-UvVersionAtLeast { + param( + [string] $Version, + [string] $Minimum + ) + + $normalizedVersion = (Convert-UvVersionOutput $Version) -replace '[-+].*$', '' + $normalizedMinimum = (Convert-UvVersionOutput $Minimum) -replace '[-+].*$', '' + if ([string]::IsNullOrWhiteSpace($normalizedVersion) -or [string]::IsNullOrWhiteSpace($normalizedMinimum)) { + throw "Unable to compare uv versions." + } + + return ([version] $normalizedVersion) -ge ([version] $normalizedMinimum) +} + +function Confirm-Uv { + if ($DryRun) { + Write-Host "+ uv --version" + return + } + + $uvCommand = Get-ApplicationCommand "uv" + if (-not $uvCommand) { + throw "uv was installed, but it is not available on PATH." + } + + $version = Get-UvVersion $uvCommand.Source + if (-not (Test-UvVersionAtLeast -Version $version -Minimum $MinUvVersion)) { + throw "uv $MinUvVersion or newer is required; found uv $version after installation." + } + Write-Host "Verified uv $version." +} + +function Ensure-Uv { + if ($DryRun) { + if (Get-ApplicationCommand "uv") { + Write-Host "+ uv --version" + Write-Host "A compatible existing uv will be left unchanged; an obsolete one will be replaced by the standalone installer." + } + else { + Write-Host "uv is not installed; the current standalone uv would be installed." + Invoke-DownloadedPowerShellInstaller -Url $UvInstallUrl -Name "uv" + Confirm-Uv + } + return + } + + $uvCommand = Get-ApplicationCommand "uv" + if ($uvCommand) { + $version = Get-UvVersion $uvCommand.Source + if (Test-UvVersionAtLeast -Version $version -Minimum $MinUvVersion) { + Write-Host "uv $version already satisfies >=$MinUvVersion; leaving it unchanged." + return + } + Write-Host "uv $version is below $MinUvVersion; installing the current standalone uv." + } + else { + Write-Host "uv is not installed; installing the current standalone uv." + } + + Invoke-DownloadedPowerShellInstaller -Url $UvInstallUrl -Name "uv" + Add-KnownBinDirectories + Confirm-Uv +} + +function Get-PackageSpec { + $includeNim = $VoiceNim + $includeLocal = $VoiceLocal + + if ($VoiceAll) { + $includeNim = $true + $includeLocal = $true + } + + if ($includeNim -and $includeLocal) { + return "free-claude-code[voice,voice_local] @ $RepoArchiveUrl" + } + if ($includeNim) { + return "free-claude-code[voice] @ $RepoArchiveUrl" + } + if ($includeLocal) { + return "free-claude-code[voice_local] @ $RepoArchiveUrl" + } + return "free-claude-code @ $RepoArchiveUrl" +} + +function Install-FreeClaudeCode { + $packageSpec = Get-PackageSpec + $arguments = @( + "tool", + "install", + "--force", + "--refresh-package", + "free-claude-code", + "--python", + $PythonVersion + ) + if (-not [string]::IsNullOrWhiteSpace($TorchBackend)) { + $arguments += @("--torch-backend", $TorchBackend) + } + $arguments += $packageSpec + + $uvPath = "uv" + if (-not $DryRun) { + $uvCommand = Get-ApplicationCommand "uv" + if (-not $uvCommand) { + throw "uv is not available for the Free Claude Code installation." + } + $uvPath = $uvCommand.Source + } + Invoke-NativeCommand -FilePath $uvPath -Arguments $arguments +} + +function Configure-AndConfirmFreeClaudeCode { + if ($DryRun) { + Write-Host "+ uv tool update-shell" + Write-Host "+ uv tool dir --bin" + Write-Host "+ verify fcc-server, fcc-claude, fcc-codex, and fcc-pi in the uv tool bin directory" + Write-Host "+ fcc-server --version" + return + } + + $uvCommand = Get-ApplicationCommand "uv" + if (-not $uvCommand) { + throw "uv is not available for PATH configuration." + } + Invoke-NativeCommand -FilePath $uvCommand.Source -Arguments @("tool", "update-shell") + $toolBin = Invoke-NativeCapture -FilePath $uvCommand.Source -Arguments @("tool", "dir", "--bin") + if ([string]::IsNullOrWhiteSpace($toolBin)) { + throw "uv returned an empty tool bin directory." + } + + Add-PathEntry $toolBin + $toolBinPath = ([IO.Path]::GetFullPath($toolBin)).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + $installedCommands = @{} + foreach ($commandName in @("fcc-server", "fcc-claude", "fcc-codex", "fcc-pi")) { + $command = Get-ApplicationCommand $commandName + if (-not $command) { + throw "Free Claude Code installation did not create '$commandName'." + } + $commandDirectory = ([IO.Path]::GetFullPath((Split-Path -Parent $command.Source))).TrimEnd( + [IO.Path]::DirectorySeparatorChar, + [IO.Path]::AltDirectorySeparatorChar + ) + if (-not $commandDirectory.Equals($toolBinPath, [StringComparison]::OrdinalIgnoreCase)) { + throw "'$commandName' resolved outside the uv tool bin directory: $($command.Source)" + } + $installedCommands[$commandName] = $command.Source + } + + Invoke-NativeCommand -FilePath $installedCommands["fcc-server"] -Arguments @("--version") +} + +if ($Help) { + Show-Usage + return +} + +if ($RemainingArgs.Count -gt 0) { + Show-Usage + throw "Unknown option: $($RemainingArgs -join ' ')" +} + +if ((-not [string]::IsNullOrWhiteSpace($TorchBackend)) -and (-not ($VoiceLocal -or $VoiceAll))) { + throw "-TorchBackend requires -VoiceLocal or -VoiceAll." +} + +Add-KnownBinDirectories + +Write-Step "Ensuring Claude Code is installed" +Ensure-ClaudeCode + +Write-Step "Ensuring Codex is installed" +Ensure-Codex + +Write-Step "Ensuring Pi is installed" +Ensure-Pi + +Write-Step "Ensuring uv $MinUvVersion or newer is installed" +Ensure-Uv + +Write-Step "Installing or updating Free Claude Code" +Install-FreeClaudeCode + +Write-Step "Configuring PATH and verifying Free Claude Code" +Configure-AndConfirmFreeClaudeCode + +Write-Host "" +if ($DryRun) { + Write-Host "Dry run complete. No changes were made." +} +else { + Write-Host "Free Claude Code is installed and verified. Start the proxy with: fcc-server" + Write-Host "Run Claude Code with: fcc-claude" + Write-Host "Run Codex with: fcc-codex" + Write-Host "Run Pi with: fcc-pi" +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..1196f1b --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,499 @@ +#!/bin/sh +set -eu + +REPO_ARCHIVE_URL="https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" +PYTHON_VERSION="3.14.0" +MIN_UV_VERSION="0.11.0" +CLAUDE_INSTALL_URL="https://claude.ai/install.sh" +CODEX_INSTALL_URL="https://chatgpt.com/codex/install.sh" +PI_INSTALL_URL="https://pi.dev/install.sh" +UV_INSTALL_URL="https://astral.sh/uv/install.sh" + +dry_run=0 +voice_nim=0 +voice_local=0 +voice_all=0 +torch_backend="" +temporary_script="" + +show_usage() { + cat <<'USAGE' +Usage: install.sh [options] + +Installs Claude Code, Codex, and Pi if missing, ensures a compatible uv, and installs or updates Free Claude Code. + +Options: + --voice-nim Install NVIDIA NIM voice transcription support. + --voice-local Install local Whisper voice transcription support. + --voice-all Install all voice transcription backends. + --torch-backend VALUE Use a uv PyTorch backend, such as cu130. Requires local voice. + --dry-run Print commands without running them. + --help Show this help text. +USAGE +} + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +step() { + printf '\n==> %s\n' "$1" +} + +quote_arg() { + case "$1" in + *[!A-Za-z0-9_./:@%+=,-]*|"") + escaped=$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g') + printf '"%s"' "$escaped" + ;; + *) + printf '%s' "$1" + ;; + esac +} + +print_command() { + printf '+' + for arg in "$@"; do + printf ' ' + quote_arg "$arg" + done + printf '\n' +} + +run() { + print_command "$@" + if [ "$dry_run" -eq 1 ]; then + return 0 + fi + + if "$@"; then + return 0 + else + status=$? + fi + + fail "Command failed with exit code $status: $1" +} + +cleanup() { + if [ -n "$temporary_script" ] && [ -e "$temporary_script" ]; then + rm -f "$temporary_script" + fi +} + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' HUP TERM + +add_path_entry() { + [ -n "$1" ] || return 0 + case ":$PATH:" in + *":$1:"*) ;; + *) PATH="$1:$PATH" ;; + esac +} + +add_known_bin_directories() { + if [ -n "${XDG_BIN_HOME:-}" ]; then + add_path_entry "$XDG_BIN_HOME" + fi + + if [ -n "${HOME:-}" ]; then + add_path_entry "$HOME/.local/bin" + add_path_entry "$HOME/.cargo/bin" + add_path_entry "${XDG_DATA_HOME:-$HOME/.local/share}/pi-node/current/bin" + fi + + export PATH + hash -r 2>/dev/null || true +} + +add_pi_bin_directories() { + [ "$dry_run" -eq 0 ] || return 0 + add_known_bin_directories + if command -v npm >/dev/null 2>&1; then + pi_npm_prefix=$(npm prefix -g 2>/dev/null || npm config get prefix 2>/dev/null || true) + if [ -n "$pi_npm_prefix" ]; then + add_path_entry "$pi_npm_prefix/bin" + export PATH + hash -r 2>/dev/null || true + fi + fi +} + +require_command() { + if [ "$dry_run" -eq 0 ] && ! command -v "$1" >/dev/null 2>&1; then + fail "$1 is required. Install it first, then rerun this installer." + fi +} + +download_and_run() { + url=$1 + interpreter=$2 + label=$3 + non_interactive=${4:-0} + + if [ "$dry_run" -eq 1 ]; then + print_command curl -fsSL "$url" -o "" + if [ "$non_interactive" -eq 1 ]; then + printf '+ CODEX_NON_INTERACTIVE=1 ' + quote_arg "$interpreter" + printf ' \n' + else + print_command "$interpreter" "" + fi + return 0 + fi + + temporary_script=$(mktemp "${TMPDIR:-/tmp}/fcc-install.XXXXXX") || fail "Unable to create a temporary file for $label." + print_command curl -fsSL "$url" -o "$temporary_script" + if curl -fsSL "$url" -o "$temporary_script"; then + : + else + status=$? + fail "Could not download the $label installer (curl exit code $status)." + fi + + if [ ! -s "$temporary_script" ]; then + fail "The downloaded $label installer was empty." + fi + + if [ "$non_interactive" -eq 1 ]; then + printf '+ CODEX_NON_INTERACTIVE=1 ' + quote_arg "$interpreter" + printf ' ' + quote_arg "$temporary_script" + printf '\n' + if CODEX_NON_INTERACTIVE=1 "$interpreter" "$temporary_script"; then + : + else + status=$? + fail "$label installation failed with exit code $status." + fi + else + print_command "$interpreter" "$temporary_script" + if "$interpreter" "$temporary_script"; then + : + else + status=$? + fail "$label installation failed with exit code $status." + fi + fi + + rm -f "$temporary_script" + temporary_script="" +} + +verify_command() { + command_name=$1 + display_name=$2 + + if [ "$dry_run" -eq 1 ]; then + print_command "$command_name" --version + return 0 + fi + + command_path=$(command -v "$command_name" 2>/dev/null) || fail "$display_name was installed, but '$command_name' is not available on PATH." + run "$command_path" --version +} + +pi_command_is_compatible() { + pi_command_path=$(command -v pi 2>/dev/null) || return 1 + pi_help=$("$pi_command_path" --help 2>/dev/null) || return 1 + case "$pi_help" in + *--extension*) ;; + *) return 1 ;; + esac + case "$pi_help" in + *--models*) return 0 ;; + *) return 1 ;; + esac +} + +verify_pi_command() { + if [ "$dry_run" -eq 1 ]; then + printf '+ pi --help (verify --extension and --models support)\n' + print_command pi --version + return 0 + fi + + pi_command_path=$(command -v pi 2>/dev/null) || fail "Pi was installed, but 'pi' is not available on PATH." + pi_command_is_compatible || fail "The 'pi' command at $pi_command_path is not a compatible Pi Coding Agent." + run "$pi_command_path" --version +} + +ensure_claude() { + if command -v claude >/dev/null 2>&1; then + printf 'Claude Code already found on PATH; verifying it.\n' + else + download_and_run "$CLAUDE_INSTALL_URL" bash "Claude Code" + add_known_bin_directories + fi + + verify_command claude "Claude Code" +} + +ensure_codex() { + if command -v codex >/dev/null 2>&1; then + printf 'Codex already found on PATH; verifying it.\n' + else + download_and_run "$CODEX_INSTALL_URL" sh "Codex" 1 + add_known_bin_directories + fi + + verify_command codex "Codex" +} + +ensure_pi() { + if [ "$dry_run" -eq 1 ] && command -v pi >/dev/null 2>&1; then + printf 'Pi already found on PATH; verifying it.\n' + elif pi_command_is_compatible; then + printf 'Pi already found on PATH; verifying it.\n' + else + if existing_pi_path=$(command -v pi 2>/dev/null); then + printf "The existing 'pi' command at %s is not Pi Coding Agent; installing Pi.\n" "$existing_pi_path" + fi + download_and_run "$PI_INSTALL_URL" sh "Pi" + add_pi_bin_directories + fi + + verify_pi_command +} + +current_uv_version() { + if output=$(uv --version); then + : + else + return 1 + fi + + case "$output" in + uv\ *) version=${output#uv } ;; + *) version=$output ;; + esac + version=${version%% *} + + case "$version" in + [0-9]*.[0-9]*.[0-9]*) printf '%s\n' "$version" ;; + *) return 1 ;; + esac +} + +version_ge() { + current=${1%%[-+]*} + minimum=${2%%[-+]*} + + old_ifs=$IFS + IFS=. + set -- $current + current_major=${1:-0} + current_minor=${2:-0} + current_patch=${3:-0} + set -- $minimum + minimum_major=${1:-0} + minimum_minor=${2:-0} + minimum_patch=${3:-0} + IFS=$old_ifs + + case "$current_major$current_minor$current_patch$minimum_major$minimum_minor$minimum_patch" in + *[!0-9]*) return 1 ;; + esac + + [ "$current_major" -gt "$minimum_major" ] && return 0 + [ "$current_major" -lt "$minimum_major" ] && return 1 + [ "$current_minor" -gt "$minimum_minor" ] && return 0 + [ "$current_minor" -lt "$minimum_minor" ] && return 1 + [ "$current_patch" -ge "$minimum_patch" ] +} + +verify_uv() { + if [ "$dry_run" -eq 1 ]; then + print_command uv --version + return 0 + fi + + command -v uv >/dev/null 2>&1 || fail "uv was installed, but it is not available on PATH." + version=$(current_uv_version) || fail "uv is present, but 'uv --version' did not return a valid version." + if ! version_ge "$version" "$MIN_UV_VERSION"; then + fail "uv $MIN_UV_VERSION or newer is required; found uv $version after installation." + fi + + printf 'Verified uv %s.\n' "$version" +} + +ensure_uv() { + if [ "$dry_run" -eq 1 ]; then + if command -v uv >/dev/null 2>&1; then + print_command uv --version + printf 'A compatible existing uv will be left unchanged; an obsolete one will be replaced by the standalone installer.\n' + else + printf 'uv is not installed; the current standalone uv would be installed.\n' + download_and_run "$UV_INSTALL_URL" sh "uv" + verify_uv + fi + return 0 + fi + + if command -v uv >/dev/null 2>&1; then + version=$(current_uv_version) || fail "uv is present, but 'uv --version' did not return a valid version." + if version_ge "$version" "$MIN_UV_VERSION"; then + printf 'uv %s already satisfies >=%s; leaving it unchanged.\n' "$version" "$MIN_UV_VERSION" + return 0 + fi + printf 'uv %s is below %s; installing the current standalone uv.\n' "$version" "$MIN_UV_VERSION" + else + printf 'uv is not installed; installing the current standalone uv.\n' + fi + + download_and_run "$UV_INSTALL_URL" sh "uv" + add_known_bin_directories + verify_uv +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --voice-nim) + voice_nim=1 + ;; + --voice-local) + voice_local=1 + ;; + --voice-all) + voice_all=1 + ;; + --torch-backend) + shift + [ "$#" -gt 0 ] || fail "--torch-backend requires a value." + torch_backend=$1 + [ -n "$torch_backend" ] || fail "--torch-backend requires a non-empty value." + ;; + --torch-backend=*) + torch_backend=${1#*=} + [ -n "$torch_backend" ] || fail "--torch-backend requires a non-empty value." + ;; + --dry-run) + dry_run=1 + ;; + --help|-h) + show_usage + exit 0 + ;; + *) + show_usage >&2 + fail "unknown option: $1" + ;; + esac + shift + done +} + +validate_args() { + include_local=$voice_local + if [ "$voice_all" -eq 1 ]; then + include_local=1 + fi + + if [ -n "$torch_backend" ] && [ "$include_local" -ne 1 ]; then + fail "--torch-backend requires --voice-local or --voice-all." + fi +} + +package_spec() { + include_nim=$voice_nim + include_local=$voice_local + + if [ "$voice_all" -eq 1 ]; then + include_nim=1 + include_local=1 + fi + + if [ "$include_nim" -eq 1 ] && [ "$include_local" -eq 1 ]; then + printf 'free-claude-code[voice,voice_local] @ %s' "$REPO_ARCHIVE_URL" + elif [ "$include_nim" -eq 1 ]; then + printf 'free-claude-code[voice] @ %s' "$REPO_ARCHIVE_URL" + elif [ "$include_local" -eq 1 ]; then + printf 'free-claude-code[voice_local] @ %s' "$REPO_ARCHIVE_URL" + else + printf 'free-claude-code @ %s' "$REPO_ARCHIVE_URL" + fi +} + +install_free_claude_code() { + spec=$(package_spec) + + if [ -n "$torch_backend" ]; then + run uv tool install --force --refresh-package free-claude-code --python "$PYTHON_VERSION" --torch-backend "$torch_backend" "$spec" + else + run uv tool install --force --refresh-package free-claude-code --python "$PYTHON_VERSION" "$spec" + fi +} + +configure_and_verify_free_claude_code() { + run uv tool update-shell + + if [ "$dry_run" -eq 1 ]; then + print_command uv tool dir --bin + printf '+ verify fcc-server, fcc-claude, fcc-codex, and fcc-pi in the uv tool bin directory\n' + print_command fcc-server --version + return 0 + fi + + print_command uv tool dir --bin + if tool_bin=$(uv tool dir --bin); then + : + else + status=$? + fail "Could not determine the uv tool bin directory (exit code $status)." + fi + [ -n "$tool_bin" ] || fail "uv returned an empty tool bin directory." + + add_path_entry "$tool_bin" + export PATH + hash -r 2>/dev/null || true + + for command_name in fcc-server fcc-claude fcc-codex fcc-pi; do + [ -x "$tool_bin/$command_name" ] || fail "Free Claude Code installation did not create $tool_bin/$command_name." + done + + run "$tool_bin/fcc-server" --version +} + +parse_args "$@" +validate_args +add_known_bin_directories + +step "Checking installation prerequisites" +require_command curl +require_command bash +require_command sh +require_command mktemp + +step "Ensuring Claude Code is installed" +ensure_claude + +step "Ensuring Codex is installed" +ensure_codex + +step "Ensuring Pi is installed" +ensure_pi + +step "Ensuring uv $MIN_UV_VERSION or newer is installed" +ensure_uv + +step "Installing or updating Free Claude Code" +install_free_claude_code + +step "Configuring PATH and verifying Free Claude Code" +configure_and_verify_free_claude_code + +if [ "$dry_run" -eq 1 ]; then + printf '\nDry run complete. No changes were made.\n' +else + printf '\nFree Claude Code is installed and verified. Start the proxy with: fcc-server\n' + printf 'Run Claude Code with: fcc-claude\n' + printf 'Run Codex with: fcc-codex\n' + printf 'Run Pi with: fcc-pi\n' +fi diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 new file mode 100644 index 0000000..06d0c87 --- /dev/null +++ b/scripts/uninstall.ps1 @@ -0,0 +1,271 @@ +param( + [switch] $DryRun, + [switch] $Help, + [Parameter(ValueFromRemainingArguments = $true)] + [object[]] $RemainingArgs = @() +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$PackageName = "free-claude-code" +$FccHomeDirname = ".fcc" +$FccCommands = @( + "fcc-server", + "fcc-claude", + "fcc-codex", + "fcc-pi", + "fcc-init", + "free-claude-code" +) +$script:UvPath = "" +$script:UvToolBin = "" + +function Show-Usage { + @" +Usage: uninstall.ps1 [options] + +Removes the Free Claude Code uv tool and deletes ~/.fcc/ after removal is verified. +Does not remove uv, Claude Code, Codex, Pi, the uv-managed Python runtime, or shared PATH entries. + +Options: + -DryRun Print commands without running them. + -Help Show this help text. +"@ +} + +function Write-Step { + param([string] $Message) + + Write-Host "" + Write-Host "==> $Message" +} + +function Format-Argument { + param([string] $Value) + + if ($Value -match '^[A-Za-z0-9_./:@%+=,\[\]\\-]+$') { + return $Value + } + return "'" + ($Value -replace "'", "''") + "'" +} + +function Format-Command { + param( + [string] $FilePath, + [string[]] $Arguments = @() + ) + + $parts = @($FilePath) + $Arguments + return ($parts | ForEach-Object { Format-Argument ([string] $_) }) -join " " +} + +function Get-ApplicationCommand { + param([string] $Name) + + $commands = @(Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue) + if ($commands.Count -eq 0) { + return $null + } + return $commands[0] +} + +function Invoke-NativeResult { + param( + [string] $FilePath, + [string[]] $Arguments + ) + + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $global:LASTEXITCODE = 0 + $output = (& $FilePath @Arguments 2>&1 | Out-String).Trim() + return [pscustomobject] @{ + ExitCode = $LASTEXITCODE + Output = $output + } + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } +} + +function Test-MissingUvToolError { + param([string] $Output) + + $normalized = $Output.ToLowerInvariant() + return $normalized.Contains($PackageName) -and $normalized.Contains("is not installed") +} + +function Add-PathEntry { + param([string] $PathEntry) + + if ([string]::IsNullOrWhiteSpace($PathEntry)) { + return + } + $separator = [IO.Path]::PathSeparator + $entries = @() + if (-not [string]::IsNullOrEmpty($env:Path)) { + $entries = $env:Path -split [regex]::Escape([string] $separator) + } + if ($entries -notcontains $PathEntry) { + $env:Path = "$PathEntry$separator$env:Path" + } +} + +function Add-KnownUvPaths { + Add-PathEntry (Join-Path $env:USERPROFILE ".local\bin") + Add-PathEntry (Join-Path $env:USERPROFILE ".cargo\bin") +} + +function Assert-NoFccProcessesRunning { + $running = @() + foreach ($commandName in $FccCommands) { + $processes = @(Get-Process -Name $commandName -ErrorAction SilentlyContinue) + if ($processes.Count -gt 0) { + $running += $commandName + } + } + if ($running.Count -gt 0) { + throw "Free Claude Code is still running ($($running -join ', ')). Stop those processes, then rerun uninstall." + } +} + +function Initialize-UvContext { + Add-KnownUvPaths + + if ($DryRun) { + Write-Host "+ uv tool dir --bin" + return + } + + $uvCommand = Get-ApplicationCommand "uv" + if (-not $uvCommand) { + throw "uv is required to remove the Free Claude Code tool. Install uv, then rerun this uninstaller; ~/.fcc was not deleted." + } + $script:UvPath = $uvCommand.Source + + $commandText = Format-Command -FilePath $script:UvPath -Arguments @("tool", "dir", "--bin") + Write-Host "+ $commandText" + $result = Invoke-NativeResult -FilePath $script:UvPath -Arguments @("tool", "dir", "--bin") + if ($result.ExitCode -ne 0) { + if (-not [string]::IsNullOrWhiteSpace($result.Output)) { + [Console]::Error.WriteLine($result.Output) + } + throw "Could not determine the uv tool bin directory (exit code $($result.ExitCode)); ~/.fcc was not deleted." + } + $script:UvToolBin = $result.Output.Trim() + if ([string]::IsNullOrWhiteSpace($script:UvToolBin)) { + throw "uv returned an empty tool bin directory; ~/.fcc was not deleted." + } +} + +function Uninstall-FreeClaudeCode { + Write-Host "+ uv tool uninstall $PackageName" + if ($DryRun) { + return + } + + $result = Invoke-NativeResult -FilePath $script:UvPath -Arguments @( + "tool", + "uninstall", + $PackageName + ) + if ($result.ExitCode -eq 0) { + if (-not [string]::IsNullOrWhiteSpace($result.Output)) { + Write-Host $result.Output + } + return + } + if (Test-MissingUvToolError -Output $result.Output) { + Write-Host "Free Claude Code uv tool is already absent; verifying its entry points." + return + } + if (-not [string]::IsNullOrWhiteSpace($result.Output)) { + [Console]::Error.WriteLine($result.Output) + } + throw "uv tool uninstall $PackageName failed with exit code $($result.ExitCode); ~/.fcc was not deleted." +} + +function Confirm-FccCommandsRemoved { + if ($DryRun) { + Write-Host "+ verify all Free Claude Code entry points are absent from the uv tool bin directory" + return + } + + $remaining = @() + $extensions = @("", ".exe", ".cmd", ".bat", ".ps1") + foreach ($commandName in $FccCommands) { + foreach ($extension in $extensions) { + $commandPath = Join-Path $script:UvToolBin "$commandName$extension" + if (Test-Path -LiteralPath $commandPath) { + $remaining += $commandPath + } + } + } + if ($remaining.Count -gt 0) { + throw "Free Claude Code entry points remain after uv uninstall: $($remaining -join ', '); ~/.fcc was not deleted." + } +} + +function Purge-FccHome { + $fccHome = Join-Path $env:USERPROFILE $FccHomeDirname + if (-not (Test-Path -LiteralPath $fccHome)) { + Write-Host "No FCC config directory at $fccHome; skipping purge." + return + } + + $commandText = @( + "Remove-Item", + "-LiteralPath", + (Format-Argument $fccHome), + "-Recurse", + "-Force" + ) -join " " + Write-Host "+ $commandText" + if ($DryRun) { + return + } + + Remove-Item -LiteralPath $fccHome -Recurse -Force + if (Test-Path -LiteralPath $fccHome) { + throw "FCC config directory still exists after deletion: $fccHome" + } +} + +if ($Help) { + Show-Usage + return +} +if ($RemainingArgs.Count -gt 0) { + Show-Usage + throw "Unknown option: $($RemainingArgs -join ' ')" +} +if ([string]::IsNullOrWhiteSpace($env:USERPROFILE)) { + throw "USERPROFILE is not set; cannot locate Free Claude Code data." +} + +Write-Step "Checking for running Free Claude Code processes" +Assert-NoFccProcessesRunning + +Write-Step "Locating the uv-managed Free Claude Code installation" +Initialize-UvContext + +Write-Step "Removing the Free Claude Code uv tool" +Uninstall-FreeClaudeCode + +Write-Step "Verifying Free Claude Code entry points were removed" +Confirm-FccCommandsRemoved + +Write-Step "Purging FCC config and data from ~/.fcc" +Purge-FccHome + +Write-Host "" +if ($DryRun) { + Write-Host "Dry run complete. No changes were made." +} +else { + Write-Host "Free Claude Code has been removed and verified." + Write-Host "uv, Claude Code, Codex, Pi, the uv-managed Python runtime, and shared PATH entries were left installed." +} diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100644 index 0000000..a9b9b54 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,243 @@ +#!/bin/sh +set -eu + +PACKAGE_NAME="free-claude-code" +FCC_HOME_DIRNAME=".fcc" +FCC_COMMANDS="fcc-server fcc-claude fcc-codex fcc-pi fcc-init free-claude-code" + +dry_run=0 +uv_tool_bin="" + +show_usage() { + cat <<'USAGE' +Usage: uninstall.sh [options] + +Removes the Free Claude Code uv tool and deletes ~/.fcc/ after removal is verified. +Does not remove uv, Claude Code, Codex, Pi, the uv-managed Python runtime, or shared PATH entries. + +Options: + --dry-run Print commands without running them. + --help Show this help text. +USAGE +} + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +step() { + printf '\n==> %s\n' "$1" +} + +quote_arg() { + case "$1" in + *[!A-Za-z0-9_./:@%+=,-]*|"") + escaped=$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g') + printf '"%s"' "$escaped" + ;; + *) + printf '%s' "$1" + ;; + esac +} + +print_command() { + printf '+' + for arg in "$@"; do + printf ' ' + quote_arg "$arg" + done + printf '\n' +} + +run() { + print_command "$@" + if [ "$dry_run" -eq 1 ]; then + return 0 + fi + + if "$@"; then + return 0 + else + status=$? + fi + fail "Command failed with exit code $status: $1" +} + +is_missing_uv_tool_error() { + normalized=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') + case "$normalized" in + *"$PACKAGE_NAME"*"is not installed"*) return 0 ;; + *) return 1 ;; + esac +} + +add_path_entry() { + [ -n "$1" ] || return 0 + case ":$PATH:" in + *":$1:"*) ;; + *) PATH="$1:$PATH" ;; + esac +} + +add_known_uv_paths() { + if [ -n "${XDG_BIN_HOME:-}" ]; then + add_path_entry "$XDG_BIN_HOME" + fi + add_path_entry "$HOME/.local/bin" + add_path_entry "$HOME/.cargo/bin" + export PATH + hash -r 2>/dev/null || true +} + +is_fcc_command_running() { + command_name=$1 + + if command -v pgrep >/dev/null 2>&1; then + if pgrep -x "$command_name" >/dev/null 2>&1; then + return 0 + fi + if pgrep -f "(^|/)${command_name}( |$)" >/dev/null 2>&1; then + return 0 + fi + return 1 + fi + + ps -A -o comm= 2>/dev/null | grep -qx "$command_name" +} + +assert_no_fcc_processes_running() { + running="" + for command_name in $FCC_COMMANDS; do + if is_fcc_command_running "$command_name"; then + running="${running} ${command_name}" + fi + done + + if [ -n "$running" ]; then + fail "Free Claude Code is still running (${running# }). Stop those processes, then rerun uninstall." + fi +} + +initialize_uv_context() { + add_known_uv_paths + + if [ "$dry_run" -eq 1 ]; then + print_command uv tool dir --bin + return 0 + fi + + if ! command -v uv >/dev/null 2>&1; then + fail "uv is required to remove the Free Claude Code tool. Install uv, then rerun this uninstaller; ~/.fcc was not deleted." + fi + + print_command uv tool dir --bin + if uv_tool_bin=$(uv tool dir --bin); then + : + else + status=$? + fail "Could not determine the uv tool bin directory (exit code $status); ~/.fcc was not deleted." + fi + [ -n "$uv_tool_bin" ] || fail "uv returned an empty tool bin directory; ~/.fcc was not deleted." +} + +uninstall_free_claude_code() { + print_command uv tool uninstall "$PACKAGE_NAME" + if [ "$dry_run" -eq 1 ]; then + return 0 + fi + + if output=$(uv tool uninstall "$PACKAGE_NAME" 2>&1); then + if [ -n "$output" ]; then + printf '%s\n' "$output" + fi + return 0 + else + status=$? + fi + + if is_missing_uv_tool_error "$output"; then + printf 'Free Claude Code uv tool is already absent; verifying its entry points.\n' + return 0 + fi + if [ -n "$output" ]; then + printf '%s\n' "$output" >&2 + fi + fail "uv tool uninstall $PACKAGE_NAME failed with exit code $status; ~/.fcc was not deleted." +} + +verify_fcc_commands_removed() { + if [ "$dry_run" -eq 1 ]; then + printf '+ verify all Free Claude Code entry points are absent from the uv tool bin directory\n' + return 0 + fi + + remaining="" + for command_name in $FCC_COMMANDS; do + command_path="$uv_tool_bin/$command_name" + if [ -e "$command_path" ] || [ -L "$command_path" ]; then + remaining="${remaining} ${command_path}" + fi + done + if [ -n "$remaining" ]; then + fail "Free Claude Code entry points remain after uv uninstall:${remaining}; ~/.fcc was not deleted." + fi +} + +purge_fcc_home() { + fcc_home="$HOME/$FCC_HOME_DIRNAME" + if [ ! -e "$fcc_home" ]; then + printf 'No FCC config directory at %s; skipping purge.\n' "$fcc_home" + return 0 + fi + + run rm -rf "$fcc_home" + if [ "$dry_run" -eq 0 ] && [ -e "$fcc_home" ]; then + fail "FCC config directory still exists after deletion: $fcc_home" + fi +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --dry-run) + dry_run=1 + ;; + --help|-h) + show_usage + exit 0 + ;; + *) + show_usage >&2 + fail "unknown option: $1" + ;; + esac + shift + done +} + +parse_args "$@" +[ -n "${HOME:-}" ] || fail "HOME is not set; cannot locate Free Claude Code data." + +step "Checking for running Free Claude Code processes" +assert_no_fcc_processes_running + +step "Locating the uv-managed Free Claude Code installation" +initialize_uv_context + +step "Removing the Free Claude Code uv tool" +uninstall_free_claude_code + +step "Verifying Free Claude Code entry points were removed" +verify_fcc_commands_removed + +step "Purging FCC config and data from ~/.fcc" +purge_fcc_home + +if [ "$dry_run" -eq 1 ]; then + printf '\nDry run complete. No changes were made.\n' +else + printf '\nFree Claude Code has been removed and verified.\n' + printf 'uv, Claude Code, Codex, Pi, the uv-managed Python runtime, and shared PATH entries were left installed.\n' +fi diff --git a/smoke/README.md b/smoke/README.md new file mode 100644 index 0000000..4087a00 --- /dev/null +++ b/smoke/README.md @@ -0,0 +1,184 @@ +# Product E2E Smoke Tests + +`smoke/` is local-only. It can launch subprocesses, call real providers, touch +local model servers, and optionally send/delete bot messages. Hermetic contracts +belong under `tests/` and must stay green with plain `uv run pytest`. + +## Taxonomy + +- `smoke/prereq/`: liveness checks that prove the server, routes, auth, CLI + scripts, provider pings, local `/models`, and bot permissions are reachable. + These are prerequisites only. +- `smoke/product/`: end-to-end product scenarios. Feature smoke coverage comes + from these tests, not from route/header/provider pings. +- `smoke/features.py`: source-of-truth feature map: + feature -> subfeature -> scenario -> env -> expected behavior -> failure class. + +## Required Local Commands + +```powershell +uv run pytest smoke --collect-only -q +uv run pytest smoke -n 0 -s --tb=short +``` + +The second command skips everything unless `FCC_LIVE_SMOKE=1` is set, but still +writes skip entries to `.smoke-results/`. + +## Product Smoke Run + +```powershell +$env:FCC_LIVE_SMOKE = "1" +uv run pytest smoke -n 0 -s --tb=short +``` + +Provider smoke scenarios can run providers in parallel while preserving +sequential execution within each provider: + +```powershell +$env:FCC_LIVE_SMOKE = "1" +$env:FCC_SMOKE_TARGETS = "providers" +uv run pytest smoke -n auto --dist=loadgroup -s --tb=short +``` + +Provider product E2E runs once per configured provider, independent of `MODEL`, +`MODEL_OPUS`, `MODEL_SONNET`, and `MODEL_HAIKU`. Defaults come from the provider +catalog/docs and can be overridden with `FCC_SMOKE_MODEL_`, for example +`FCC_SMOKE_MODEL_DEEPSEEK=deepseek-v4-pro` (or `deepseek-v4-flash`). If no provider smoke model is +configured, live product smoke fails as `missing_env` unless you explicitly set +`FCC_ALLOW_NO_PROVIDER_SMOKE=1`. + +## Targets + +Default targets do not send real bot messages or load voice backends: + +| Target | Product scenarios | Required environment | +| --- | --- | --- | +| `api` | messages, count_tokens full payload, errors, `/stop`, optimizations | configured provider only for streaming messages | +| `auth` | x-api-key, bearer, anthropic-auth-token, invalid/missing auth | none; test sets an isolated token | +| `cli` | `fcc-init`, server entrypoint, Claude CLI adaptive thinking, session cleanup | Claude CLI binary and provider only for real CLI | +| `clients` | VS Code and JetBrains protocol payloads | configured provider | +| `config` | env precedence, removed-env migration, proxy/timeouts | none | +| `extensibility` | provider runtime and platform factory construction | none | +| `messaging` | fake Discord/Telegram full flow, literal clear scopes, trees, persistence, voice cancel | none | +| `providers` | multi-turn text, adaptive thinking history, tools, disconnect, errors | configured providers, optional `FCC_SMOKE_MODEL_*` | +| `tools` | forced tool_use and tool_result continuation | tool-capable configured provider | +| `rate_limit` | disconnect cleanup and follow-up request | configured provider | +| `lmstudio` | local `/models` plus OpenAI-chat-backed Messages through proxy | running LM Studio server | +| `llamacpp` | local `/models` plus OpenAI-chat-backed Messages through proxy | running llama-server | +| `ollama` | local `/v1/models` plus OpenAI-chat-backed Messages through proxy | running Ollama server | + +Heavy/side-effectful targets are opt-in: + +| Target | Product scenarios | Required environment | +| --- | --- | --- | +| `nvidia_nim_cli` | Claude Code CLI feature matrix across NIM models | `NVIDIA_NIM_API_KEY`, Claude CLI | +| `openrouter_free_cli` | Claude Code CLI feature matrix across OpenRouter free models | `OPENROUTER_API_KEY`, Claude CLI | +| `telegram` | getMe, send, edit, delete, optional manual inbound | token and chat/user ID | +| `discord` | channel access, send, edit, delete, optional manual inbound | token and channel ID | +| `voice` | generated WAV through local Whisper or NVIDIA NIM transcription | `VOICE_NOTE_ENABLED=true`, `FCC_SMOKE_RUN_VOICE=1` | + +## Examples + +```powershell +$env:FCC_LIVE_SMOKE = "1" +$env:FCC_SMOKE_PROVIDER_MATRIX = "open_router,nvidia_nim,deepseek,lmstudio,llamacpp,ollama" +uv run pytest smoke/product -n 0 -s --tb=short +``` + +```powershell +$env:FCC_LIVE_SMOKE = "1" +$env:FCC_SMOKE_TARGETS = "ollama" +$env:OLLAMA_BASE_URL = "http://localhost:11434" +uv run pytest smoke/prereq smoke/product -n 0 -s --tb=short +``` + +```powershell +$env:FCC_LIVE_SMOKE = "1" +$env:FCC_SMOKE_TARGETS = "telegram,discord,voice" +$env:FCC_SMOKE_RUN_VOICE = "1" +uv run pytest smoke/product -n 0 -s --tb=short +``` + +```powershell +$env:FCC_LIVE_SMOKE = "1" +$env:FCC_SMOKE_TARGETS = "nvidia_nim_cli" +$env:FCC_SMOKE_NIM_MODELS = "z-ai/glm-5.2,moonshotai/kimi-k2.6,minimaxai/minimax-m2.7,nvidia/nemotron-3-super-120b-a12b,deepseek-ai/deepseek-v4-pro,deepseek-ai/deepseek-v4-flash" +uv run pytest smoke/product -n 0 -s --tb=short +``` + +```powershell +$env:FCC_LIVE_SMOKE = "1" +$env:FCC_SMOKE_TARGETS = "openrouter_free_cli" +$env:FCC_SMOKE_OPENROUTER_FREE_MODELS = "nvidia/nemotron-3-super-120b-a12b:free,openai/gpt-oss-120b:free,poolside/laguna-m.1:free" +uv run pytest smoke/product -n 0 -s --tb=short +``` + +```powershell +$env:FCC_LIVE_SMOKE = "1" +$env:FCC_SMOKE_TARGETS = "messaging,config,extensibility" +uv run pytest smoke/product -n 0 -s --tb=short +``` + +## Environment + +- `FCC_ENV_FILE`: explicit dotenv path for startup/config scenarios. +- `FCC_LIVE_SMOKE=1`: enables live smoke execution. +- `FCC_ALLOW_NO_PROVIDER_SMOKE=1`: permits no-provider live smoke for harness work. +- `FCC_SMOKE_TARGETS`: comma-separated targets, or `all`. +- `FCC_SMOKE_PROVIDER_MATRIX`: comma-separated provider prefixes to require. +- `FCC_SMOKE_MODEL_NVIDIA_NIM`, `FCC_SMOKE_MODEL_OPEN_ROUTER`, + `FCC_SMOKE_MODEL_MISTRAL`, `FCC_SMOKE_MODEL_MISTRAL_REASONING`, + `FCC_SMOKE_MODEL_MISTRAL_CODESTRAL`, + `FCC_SMOKE_MODEL_DEEPSEEK`, `FCC_SMOKE_MODEL_KIMI`, + `FCC_SMOKE_MODEL_WAFER`, `FCC_SMOKE_MODEL_MINIMAX`, + `FCC_SMOKE_MODEL_OPENCODE`, `FCC_SMOKE_MODEL_OPENCODE_GO`, + `FCC_SMOKE_MODEL_ZAI`, `FCC_SMOKE_MODEL_FIREWORKS`, `FCC_SMOKE_MODEL_CLOUDFLARE`, + `FCC_SMOKE_MODEL_GEMINI`, `FCC_SMOKE_MODEL_GROQ`, `FCC_SMOKE_MODEL_CEREBRAS`, + `FCC_SMOKE_MODEL_LMSTUDIO`, + `FCC_SMOKE_MODEL_LLAMACPP`, `FCC_SMOKE_MODEL_OLLAMA`: optional per-provider + smoke model overrides. Values may include the provider prefix or just the model + name for that provider. +- `FCC_SMOKE_MODEL_MISTRAL_REASONING`: optional override for the dedicated + Mistral native reasoning smoke, default `mistral/mistral-medium-3-5`. +- `FCC_SMOKE_NIM_MODELS`: optional comma-separated NVIDIA NIM CLI matrix models + that replace the default characterization set. +- `FCC_SMOKE_NIM_EXTRA_MODELS`: optional comma-separated NVIDIA NIM CLI matrix + models appended to the default or replacement set. +- `FCC_SMOKE_OPENROUTER_FREE_MODELS`: optional comma-separated OpenRouter free + CLI matrix models that replace the default characterization set. +- `FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS`: optional comma-separated OpenRouter + free CLI matrix models appended to the default or replacement set. +- `FCC_SMOKE_TIMEOUT_S`: per-request/subprocess timeout, default `45`. +- `FCC_SMOKE_CLAUDE_BIN`: Claude CLI executable name, default `claude`. +- `FCC_SMOKE_TELEGRAM_CHAT_ID`: Telegram chat/user ID for send/edit/delete. +- `FCC_SMOKE_DISCORD_CHANNEL_ID`: Discord channel ID for send/edit/delete. +- `FCC_SMOKE_INTERACTIVE=1`: enables manual inbound Telegram/Discord checks. +- `FCC_SMOKE_RUN_VOICE=1`: allows voice transcription backends to load/run. + +## Windows / nested `uv run` + +Run smoke the same way you run tests (`uv run pytest smoke` from the repo). Child +processes use the **same Python interpreter** as the test runner, not nested +`uv run`, so Windows does not try to replace `free-claude-code.exe` while it is +locked. + +## Failure Classes + +Smoke artifacts are written to `.smoke-results/` and redact env values whose +names contain `KEY`, `TOKEN`, `SECRET`, `WEBHOOK`, or `AUTH`. + +- `missing_env`: required credentials, binary, provider config, local provider + server/model, or opt-in flag is absent. +- `upstream_unavailable`: a real provider or bot API is not reachable. +- `probe_timeout`: the smoke driver reached the target, but the CLI/probe did + not complete within the smoke timeout. +- `product_failure`: the app accepted the scenario but returned the wrong shape, + crashed, leaked state, or violated the product contract. +- `harness_bug`: the smoke test or driver made an invalid assumption. +- `target_disabled`: skipped because `FCC_SMOKE_TARGETS` intentionally selected + a different target. + +`product_failure` and `harness_bug` are failures. `missing_env`, +`upstream_unavailable`, and `probe_timeout` are skips except when the user +explicitly selected a provider in `FCC_SMOKE_PROVIDER_MATRIX`; +selected-but-missing providers fail. diff --git a/smoke/__init__.py b/smoke/__init__.py new file mode 100644 index 0000000..1eee0aa --- /dev/null +++ b/smoke/__init__.py @@ -0,0 +1 @@ +"""Local-only live smoke tests for free-claude-code.""" diff --git a/smoke/capabilities.py b/smoke/capabilities.py new file mode 100644 index 0000000..8001d9f --- /dev/null +++ b/smoke/capabilities.py @@ -0,0 +1,524 @@ +"""Hierarchical public capability map. + +This module is the architectural companion to ``smoke.features``. The feature +inventory says which public features must be covered; this map records the +subfeature contracts, owning module boundary, and coverage owners that protect +them while the internals are refactored. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CapabilityContract: + capability: str + subfeature: str + feature_id: str + owner: str + inputs: str + outputs: str + failure: str + pytest_tests: tuple[str, ...] + smoke_tests: tuple[str, ...] = () + + +CAPABILITY_CONTRACTS: tuple[CapabilityContract, ...] = ( + CapabilityContract( + "api_compatibility", + "routes_and_probes", + "anthropic_api_routes", + "free_claude_code.api.routes", + "Anthropic-compatible HTTP requests", + "JSON or Anthropic SSE responses", + "HTTPException or Anthropic error payload", + ("tests/api/test_api.py",), + ("test_probe_and_models_routes", "test_stop_endpoint_reports_no_messaging"), + ), + CapabilityContract( + "api_compatibility", + "head_options_probes", + "probe_routes", + "free_claude_code.api.routes", + "HEAD/OPTIONS probe requests", + "204 with Allow header", + "auth failure when configured", + ("tests/api/test_api.py::test_probe_endpoints_return_204_with_allow_headers",), + ("test_probe_and_models_routes",), + ), + CapabilityContract( + "api_compatibility", + "client_request_shapes", + "drop_in_claude_code_replacement", + "free_claude_code.api.handlers.messages.MessagesHandler", + "Claude Code, VS Code, and JetBrains shaped requests", + "compatible streaming response", + "provider or validation error in Anthropic shape", + ("tests/api/test_api.py", "tests/cli/test_cli.py"), + ("test_vscode_and_jetbrains_shaped_requests",), + ), + CapabilityContract( + "api_compatibility", + "responses_api", + "drop_in_codex_replacement", + "free_claude_code.api.handlers.responses.ResponsesHandler", + "OpenAI Responses requests from Codex", + "Responses SSE or JSON response", + "OpenAI-shaped error or conversion error", + ( + "tests/api/test_openai_responses.py", + "tests/core/openai_responses/test_sse.py", + "tests/cli/test_entrypoints.py", + "tests/cli/test_codex_model_catalog.py", + ), + ( + "test_probe_and_models_routes", + "test_provider_codex_responses_text_e2e", + ), + ), + CapabilityContract( + "api_compatibility", + "client_extensions", + "vscode_extension", + "free_claude_code.api.handlers.messages.MessagesHandler", + "VS Code beta query/header variants", + "accepted Anthropic-compatible response", + "HTTP error only for auth/provider failures", + (), + ("test_vscode_and_jetbrains_shaped_requests",), + ), + CapabilityContract( + "api_compatibility", + "client_extensions", + "intellij_extension", + "free_claude_code.api.handlers.messages.MessagesHandler", + "JetBrains/ACP request variants", + "accepted Anthropic-compatible response", + "HTTP error only for auth/provider failures", + (), + ("test_vscode_and_jetbrains_shaped_requests",), + ), + CapabilityContract( + "auth", + "anthropic_headers", + "optional_authentication", + "free_claude_code.api.dependencies.require_api_key", + "x-api-key, authorization, anthropic-auth-token", + "request accepted or 401", + "401 missing/invalid API key", + ("tests/api/test_auth.py",), + ("test_auth_token_is_enforced_for_all_supported_header_shapes",), + ), + CapabilityContract( + "provider_routing", + "provider_runtime", + "provider_matrix", + "free_claude_code.runtime.provider_manager.ProviderRuntimeManager", + "provider id and Settings", + "configured BaseProvider instance", + "503 for missing credentials; invalid_request_error for unknown provider", + ("tests/api/test_dependencies.py", "tests/providers/test_provider_runtime.py"), + ( + "test_configured_provider_models_stream_successfully", + "test_provider_matrix_presence_e2e", + ), + ), + CapabilityContract( + "provider_routing", + "claude_model_resolution", + "per_model_mapping", + "free_claude_code.application.routing.ModelRouter", + "Claude model name and configured MODEL_* values", + "provider id plus provider model name", + "settings validation rejects invalid provider prefixes", + ("tests/application/test_routing.py", "tests/config/test_config.py"), + ("test_model_mapping_configuration_is_consistent",), + ), + CapabilityContract( + "provider_routing", + "mixed_provider_resolution", + "mixed_provider_mapping", + "free_claude_code.application.routing.ModelRouter", + "Opus/Sonnet/Haiku/fallback model config", + "distinct provider selections per request", + "skip live execution when providers are not configured", + ("tests/application/test_routing.py", "tests/config/test_config.py"), + ("test_mixed_provider_model_mapping_when_configured",), + ), + CapabilityContract( + "provider_routing", + "provider_runtime_config", + "provider_proxy_timeout_config", + "free_claude_code.providers.runtime.ProviderRuntime", + "provider proxy, timeout, and rate-limit settings", + "provider client and instance-owned limiter config", + "provider construction failure", + ( + "tests/api/test_dependencies.py", + "tests/providers/test_provider_runtime.py", + "tests/providers/test_provider_rate_limit.py", + ), + ), + CapabilityContract( + "provider_routing", + "zero_cost_backends", + "zero_cost_provider_access", + "free_claude_code.providers.runtime.ProviderRuntime", + "configured free/local provider", + "streaming response from selected backend", + "missing env or upstream unavailable skip in smoke", + ("tests/api/test_dependencies.py", "tests/providers/"), + ("test_configured_provider_models_stream_successfully",), + ), + CapabilityContract( + "streaming_conversion", + "anthropic_sse_lifecycle", + "streaming_error_mapping", + "free_claude_code.core.failures.ExecutionFailure", + "provider stream failures before or after the HTTP commit boundary", + "protocol-specific JSON failure or terminal stream event", + "ordinary request failures remain distinct from terminal execution", + ( + "tests/api/test_execution_failure_contract.py", + "tests/api/test_ordinary_error_phases.py", + "tests/core/test_failure_protocol_mapping.py", + "tests/providers/test_execution_failure_boundary.py", + "tests/providers/test_failure_policy.py", + ), + ), + CapabilityContract( + "streaming_conversion", + "thinking_blocks", + "thinking_token_support", + "free_claude_code.core.anthropic.thinking", + "reasoning_content, reasoning_details, text, native thinking", + "Claude thinking blocks or suppression", + "thinking hidden when disabled", + ( + "tests/contracts/test_stream_contracts.py", + "tests/providers/test_converter.py", + "tests/providers/test_deepseek.py", + "tests/providers/test_nvidia_nim_request.py", + "tests/providers/test_open_router.py", + ), + ( + "test_per_model_thinking_config_e2e", + "test_provider_reasoning_tool_continuation_e2e", + ), + ), + CapabilityContract( + "streaming_conversion", + "heuristic_tools", + "heuristic_tool_parser", + "free_claude_code.core.anthropic.tools", + "textual tool-call output", + "structured Anthropic tool_use blocks", + "text fallback when malformed", + ("tests/providers/test_parsers.py", "tests/contracts/test_stream_contracts.py"), + ( + "test_live_tool_use_when_configured_model_supports_tools", + "test_provider_reasoning_tool_continuation_e2e", + ), + ), + CapabilityContract( + "streaming_conversion", + "subagent_task_control", + "subagent_control", + "free_claude_code.core.anthropic.streaming.AnthropicStreamLedger", + "Task tool call arguments", + "run_in_background=false", + "invalid JSON flushed as safe object", + ("tests/providers/test_subagent_interception.py",), + ), + CapabilityContract( + "request_behavior", + "local_optimizations", + "request_optimization", + "free_claude_code.api.optimization_handlers", + "Claude Code probe/title/suggestion/filepath requests", + "local MessagesResponse without provider call", + "falls through to provider when unmatched/disabled", + ( + "tests/api/test_optimization_handlers.py", + "tests/api/test_routes_optimizations.py", + ), + ("test_optimization_fast_paths_do_not_need_provider",), + ), + CapabilityContract( + "request_behavior", + "token_counting", + "count_tokens_contract", + "free_claude_code.core.anthropic.get_token_count", + "messages, system blocks, tools, images, thinking, tool results", + "positive input token estimate", + "500 with readable detail on unexpected failure", + ("tests/api/test_request_utils.py",), + ("test_count_tokens_accepts_thinking_tools_and_results",), + ), + CapabilityContract( + "config", + "env_precedence", + "config_env_precedence", + "free_claude_code.config.settings.Settings", + "process env, user env file, repo env file, FCC_ENV_FILE", + "deterministic settings values", + "validation error for invalid settings", + ("tests/config/test_config.py",), + ), + CapabilityContract( + "config", + "removed_env_migration", + "removed_env_migration", + "free_claude_code.config.settings.Settings", + "NIM_ENABLE_THINKING or ENABLE_THINKING in env or dotenv", + "startup succeeds and stale keys do not change thinking defaults", + "removed key ignored", + ("tests/config/test_config.py",), + ), + CapabilityContract( + "provider_runtime", + "rate_limit_and_disconnect", + "smart_rate_limiting", + "free_claude_code.providers.rate_limit.ProviderRateLimiter", + "concurrent provider requests and transient upstream/disconnect failures", + "provider-specific classification, proactive throttle, retry, cleanup", + "mapped provider error or smoke skip for upstream disconnect", + ( + "tests/providers/test_provider_rate_limit.py", + "tests/providers/test_nvidia_nim_degraded_retry.py", + ), + ("test_client_disconnect_mid_stream_does_not_crash_server",), + ), + CapabilityContract( + "provider_runtime", + "generation_hot_swap", + "provider_hot_swap", + "free_claude_code.runtime.provider_manager.ProviderRuntimeManager", + "validated Admin provider config and concurrent request streams", + "atomic generation publication with old-stream completion", + "failed candidate or persistence leaves current generation unchanged", + ( + "tests/runtime/test_provider_manager.py", + "tests/api/test_response_streams.py", + "tests/api/test_admin.py", + ), + ("test_provider_hot_swap_preserves_inflight_stream_e2e",), + ), + CapabilityContract( + "local_providers", + "lmstudio_messages", + "lmstudio_endpoint", + "free_claude_code.providers.lmstudio.LMStudioProvider", + "Anthropic Messages request and local LM Studio URL", + "Anthropic SSE converted from an OpenAI Chat upstream", + "typed provider failure for local upstream errors", + ("tests/providers/test_lmstudio.py",), + ("test_lmstudio_messages_e2e",), + ), + CapabilityContract( + "local_providers", + "llamacpp_openai_chat", + "llamacpp_endpoint", + "free_claude_code.providers.openai_chat.OpenAIChatProvider", + "OpenAI Chat request and llama.cpp URL", + "Anthropic SSE converted from OpenAI Chat chunks", + "typed provider failure for local upstream errors", + ("tests/providers/test_llamacpp.py",), + ("test_llamacpp_models_endpoint_when_available",), + ), + CapabilityContract( + "local_providers", + "ollama_openai_chat", + "ollama_endpoint", + "free_claude_code.providers.openai_chat.OpenAIChatProvider", + "OpenAI Chat request and local Ollama root URL", + "Anthropic SSE converted from OpenAI Chat chunks", + "typed provider failure for local upstream errors", + ("tests/providers/test_ollama.py",), + ("test_ollama_models_endpoint_when_available",), + ), + CapabilityContract( + "openrouter", + "openai_chat_provider", + "provider_matrix", + "free_claude_code.providers.open_router.OpenRouterProvider", + "OpenAI Chat request for OpenRouter", + "OpenAI stream mapped to Anthropic SSE", + "Anthropic SSE error shape", + ( + "tests/providers/test_open_router.py", + "tests/providers/test_openai_compat_5xx_retry.py", + ), + ("test_configured_provider_models_stream_successfully",), + ), + CapabilityContract( + "messaging", + "discord_telegram_platforms", + "discord_telegram_bot", + "free_claude_code.messaging.workflow.MessagingWorkflow", + "Discord/Telegram incoming messages and API callbacks", + "live progress edits and final transcript", + "platform retry/fallback or user-facing error", + ( + "tests/messaging/test_discord_platform.py", + "tests/messaging/test_telegram.py", + "tests/messaging/test_limiter.py", + "tests/messaging/test_platform_outbox.py", + "tests/runtime/test_application_runtime.py", + ), + ("test_telegram_bot_api_permissions", "test_discord_bot_api_permissions"), + ), + CapabilityContract( + "messaging", + "commands", + "messaging_commands", + "free_claude_code.messaging.commands", + "/stop, /clear, /stats command messages", + "chat-wide cleanup or literal reply-subtree deletion", + "terminal status edit, no-op feedback, or best-effort platform cleanup", + ( + "tests/messaging/test_handler.py", + "tests/messaging/test_handler_integration.py", + "tests/messaging/test_tree_ownership_concurrency.py", + ), + ( + "test_messaging_commands_stop_clear_stats_e2e", + "test_reply_clear_uses_literal_platform_subtree_e2e", + "test_messaging_startup_notice_is_clearable_e2e", + "test_messaging_active_stop_uses_status_only_e2e", + "test_messaging_queued_scoped_cancel_e2e", + ), + ), + CapabilityContract( + "messaging", + "tree_threading", + "tree_threading", + "free_claude_code.messaging.trees.TreeQueueManager", + "reply-based message graph", + "queued branches and scoped cancellation", + "node error propagation", + ( + "tests/messaging/test_tree_queue.py", + "tests/messaging/test_tree_concurrency.py", + "tests/messaging/test_message_tree_transitions.py", + "tests/messaging/test_tree_ownership_concurrency.py", + ), + ( + "test_tree_threading_e2e", + "test_messaging_queued_scoped_cancel_e2e", + "test_same_message_ids_are_isolated_by_chat_e2e", + ), + ), + CapabilityContract( + "persistence", + "restart_restore", + "restart_restore", + "free_claude_code.messaging.session.SessionStore", + "stored tree JSON", + "restored scoped reply lookup", + "stale pending work marked lost", + ("tests/messaging/test_restart_reply_restore.py",), + ), + CapabilityContract( + "persistence", + "session_store", + "session_persistence", + "free_claude_code.messaging.session.SessionStore", + "scoped tree data and message log", + "JSON persistence compatible with existing files", + "best-effort flush error logging", + ("tests/messaging/test_session_store_edge_cases.py",), + ), + CapabilityContract( + "voice", + "voice_transcription", + "voice_notes", + "free_claude_code.messaging.voice.Transcriber", + "Discord/Telegram audio file and voice backend settings", + "transcribed prompt routed to handler", + "missing optional extra or backend error shown to user", + ( + "tests/messaging/test_voice_handlers.py", + "tests/messaging/test_transcription.py", + "tests/messaging/test_transcription_nim.py", + "tests/messaging/test_platform_voice_flow.py", + ), + ("test_voice_transcription_backend_when_explicitly_enabled",), + ), + CapabilityContract( + "cli", + "package_entrypoints", + "package_cli_entrypoints", + "free_claude_code.cli.entrypoints", + "installed console scripts", + "config scaffold, package version, or uvicorn server startup", + "process cleanup in finally", + ("tests/cli/test_entrypoints.py", "tests/core/test_version.py"), + ( + "test_fcc_init_scaffolds_user_config", + "test_free_claude_code_entrypoint_starts_server", + "test_entrypoint_version_e2e", + ), + ), + CapabilityContract( + "cli", + "claude_cli_drop_in", + "claude_cli_drop_in", + "free_claude_code.cli.managed.session.ManagedClaudeSession", + "Claude CLI binary and proxy env", + "stream-json events and session id mapping", + "stderr/error event and process cleanup", + ("tests/cli/test_cli.py",), + ( + "test_claude_cli_prompt_when_available", + "test_claude_cli_provider_error_e2e", + "test_nvidia_nim_cli_matrix_e2e", + "test_openrouter_free_cli_matrix_e2e", + ), + ), + CapabilityContract( + "cli", + "codex_cli_drop_in", + "drop_in_codex_replacement", + "free_claude_code.cli.launchers.codex", + "Codex CLI binary and fcc provider env", + "Responses config, auth env, and native /model catalog injection", + "proxy preflight and catalog fail-open warning", + ( + "tests/cli/test_codex_model_catalog.py", + "tests/cli/test_entrypoints.py", + ), + (), + ), + CapabilityContract( + "cli", + "pi_cli_integration", + "pi_cli_integration", + "free_claude_code.cli.launchers.pi", + "Pi CLI binary, bundled extension, and FCC child-process environment", + "Anthropic Messages provider with an FCC-scoped live model catalog", + "proxy preflight, missing extension, or catalog failure exits without fallback", + ("tests/cli/test_entrypoints.py",), + ("test_pi_cli_prompt_e2e",), + ), + CapabilityContract( + "extensibility", + "provider_platform_abcs", + "extensible_provider_platform_abcs", + "free_claude_code.providers.runtime and free_claude_code.messaging.platforms.factory", + "new provider/platform implementations", + "registered BaseProvider or messaging component bundle", + "unknown platform returns None; unknown provider errors", + ( + "tests/contracts/test_feature_manifest.py", + "tests/providers/test_provider_runtime.py", + ), + ), +) + + +def capability_names() -> set[str]: + return {contract.capability for contract in CAPABILITY_CONTRACTS} + + +def contracted_feature_ids() -> set[str]: + return {contract.feature_id for contract in CAPABILITY_CONTRACTS} diff --git a/smoke/conftest.py b/smoke/conftest.py new file mode 100644 index 0000000..557decd --- /dev/null +++ b/smoke/conftest.py @@ -0,0 +1,126 @@ +from collections.abc import Iterator +from typing import Any + +import pytest + +from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers +from smoke.lib.report import SmokeReport +from smoke.lib.server import RunningServer, start_server + +DISABLED_PROVIDER_MODEL = ProviderModel( + provider="smoke_disabled", + full_model="smoke_disabled/smoke-disabled", + source="smoke_disabled", +) + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + if "provider_model" in metafunc.fixturenames: + config = SmokeConfig.load() + metafunc.parametrize("provider_model", provider_model_params(config)) + + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + if SmokeConfig.load().live: + return + skip = pytest.mark.skip(reason="set FCC_LIVE_SMOKE=1 to run local smoke tests") + for item in items: + item.add_marker(skip) + + +def pytest_configure(config: pytest.Config) -> None: + global _REPORT + smoke_config = SmokeConfig.load() + _REPORT = SmokeReport(smoke_config) + + +def pytest_runtest_setup(item: pytest.Item) -> None: + config = SmokeConfig.load() + target_marks = list(item.iter_markers("smoke_target")) + if not target_marks: + return + targets = [str(mark.args[0]) for mark in target_marks if mark.args] + if targets and not any(config.target_enabled(target) for target in targets): + pytest.skip(f"smoke target disabled: {', '.join(targets)}") + + +def pytest_runtest_logreport(report: pytest.TestReport) -> None: + if report.when == "setup" and not report.skipped: + return + if report.when == "teardown" and not report.failed: + return + if _REPORT is None: + return + markers = sorted( + str(name) for name in report.keywords if str(name).startswith("smoke_") + ) + detail = "" if report.longrepr is None else str(report.longrepr) + _REPORT.add( + nodeid=report.nodeid, + outcome=report.outcome, + duration_s=report.duration, + markers=markers, + detail=detail, + ) + + +def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: + if _REPORT is not None: + _REPORT.write() + + +@pytest.fixture(scope="session") +def smoke_config() -> SmokeConfig: + return SmokeConfig.load() + + +@pytest.fixture +def smoke_server(smoke_config: SmokeConfig) -> Iterator[RunningServer]: + with start_server(smoke_config) as server: + yield server + + +@pytest.fixture +def smoke_headers() -> dict[str, str]: + return auth_headers() + + +def provider_model_params(config: SmokeConfig) -> list[Any]: + """Return provider params grouped for pytest-xdist ``--dist=loadgroup``.""" + if not config.live: + return [_disabled_provider_param("set FCC_LIVE_SMOKE=1 to run provider smoke")] + + models = config.provider_smoke_models() + if not models: + return [_disabled_provider_param("missing_env: no configured provider smoke")] + + return [ + pytest.param( + model, + id=provider_model_id(model), + marks=pytest.mark.xdist_group(provider_xdist_group(model)), + ) + for model in models + ] + + +def _disabled_provider_param(reason: str) -> Any: + return pytest.param( + DISABLED_PROVIDER_MODEL, + id=provider_model_id(DISABLED_PROVIDER_MODEL), + marks=( + pytest.mark.skip(reason=reason), + pytest.mark.xdist_group(provider_xdist_group(DISABLED_PROVIDER_MODEL)), + ), + ) + + +def provider_model_id(provider_model: ProviderModel) -> str: + return provider_model.provider + + +def provider_xdist_group(provider_model: ProviderModel) -> str: + return f"provider:{provider_model.provider}" + + +_REPORT: SmokeReport | None = None diff --git a/smoke/features.py b/smoke/features.py new file mode 100644 index 0000000..7358805 --- /dev/null +++ b/smoke/features.py @@ -0,0 +1,565 @@ +"""Public feature inventory for contract, prerequisite, and product smoke tests. + +The inventory is intentionally explicit. README-advertised behavior and exposed +public surface area must have deterministic pytest contract coverage plus a +product E2E scenario when that behavior is a user-facing product path. Liveness +and route probes live in ``smoke/prereq`` and do not count as product coverage. +""" + +from dataclasses import dataclass +from typing import Literal + +FeatureSource = Literal["readme", "public_surface"] + + +@dataclass(frozen=True, slots=True) +class FeatureCoverage: + feature_id: str + title: str + source: FeatureSource + pytest_contract_tests: tuple[str, ...] + live_prereq_tests: tuple[str, ...] + product_e2e_tests: tuple[str, ...] + smoke_targets: tuple[str, ...] + required_env: tuple[str, ...] + skip_policy: str + product_e2e_reason: str = "" + + @property + def has_pytest_coverage(self) -> bool: + return bool(self.pytest_contract_tests) + + +README_FEATURES: tuple[str, ...] = ( + "zero_cost_provider_access", + "drop_in_claude_code_replacement", + "drop_in_codex_replacement", + "pi_cli_integration", + "provider_matrix", + "per_model_mapping", + "thinking_token_support", + "heuristic_tool_parser", + "discord_telegram_bot", + "optional_authentication", + "vscode_extension", + "intellij_extension", + "voice_notes", +) + + +FEATURE_INVENTORY: tuple[FeatureCoverage, ...] = ( + FeatureCoverage( + "zero_cost_provider_access", + "Configured provider accepts real conversation turns", + "readme", + ("tests/api/test_dependencies.py", "tests/providers/"), + ("test_configured_provider_models_stream_successfully",), + ("test_provider_text_multiturn_e2e",), + ("providers",), + ("configured provider credentials or local provider endpoint",), + "missing providers are missing_env unless FCC_ALLOW_NO_PROVIDER_SMOKE=1", + ), + FeatureCoverage( + "drop_in_claude_code_replacement", + "Claude-compatible API, CLI, and editor protocol flows work", + "readme", + ("tests/api/test_api.py", "tests/cli/test_cli.py"), + ("test_probe_and_models_routes", "test_claude_cli_prompt_when_available"), + ( + "test_api_basic_conversation_e2e", + "test_claude_cli_adaptive_thinking_e2e", + "test_claude_cli_provider_error_e2e", + "test_nvidia_nim_cli_matrix_e2e", + "test_openrouter_free_cli_matrix_e2e", + "test_vscode_protocol_e2e", + "test_jetbrains_protocol_e2e", + ), + ("api", "cli", "clients", "nvidia_nim_cli", "openrouter_free_cli"), + ( + "configured provider", + "FCC_SMOKE_CLAUDE_BIN for real Claude CLI", + "NVIDIA_NIM_API_KEY", + "OPENROUTER_API_KEY", + ), + "skip real CLI when binary is absent; configured providers must pass", + ), + FeatureCoverage( + "drop_in_codex_replacement", + "OpenAI Responses API and Codex CLI adapter route through the proxy", + "readme", + ( + "tests/api/test_openai_responses.py", + "tests/cli/test_entrypoints.py", + "tests/cli/test_codex_model_catalog.py", + "tests/core/openai_responses/test_sse.py", + ), + ("test_probe_and_models_routes",), + ("test_provider_codex_responses_text_e2e",), + ("api", "providers"), + ("configured provider credentials or local provider endpoint",), + "missing providers are missing_env unless FCC_ALLOW_NO_PROVIDER_SMOKE=1", + ), + FeatureCoverage( + "pi_cli_integration", + "Pi discovers FCC models and sends Anthropic Messages through the proxy", + "readme", + ("tests/cli/test_entrypoints.py",), + ("test_probe_and_models_routes",), + ("test_pi_cli_prompt_e2e",), + ("clients",), + ("Pi CLI", "configured provider credentials or local provider endpoint"), + "skip only when Pi is absent; configured providers must pass", + ), + FeatureCoverage( + "provider_matrix", + "Every configured provider prefix can satisfy conversation scenarios", + "readme", + ("tests/api/test_dependencies.py", "tests/providers/"), + ("test_configured_provider_models_stream_successfully",), + ("test_provider_matrix_presence_e2e", "test_provider_text_multiturn_e2e"), + ("providers",), + ("configured provider credentials/endpoints", "optional FCC_SMOKE_MODEL_*"), + "selected providers missing credentials are failing missing_env", + ), + FeatureCoverage( + "per_model_mapping", + "Opus, Sonnet, Haiku, and fallback mappings route explicitly", + "readme", + ("tests/application/test_routing.py", "tests/config/test_config.py"), + ("test_model_mapping_configuration_is_consistent",), + ("test_model_mapping_matrix_e2e",), + ("providers",), + ("MODEL", "MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"), + "skip only when live smoke is intentionally allowed to run with no provider", + ), + FeatureCoverage( + "mixed_provider_mapping", + "Model-specific overrides can route to different providers", + "public_surface", + ("tests/application/test_routing.py", "tests/config/test_config.py"), + ("test_mixed_provider_model_mapping_when_configured",), + ("test_model_mapping_matrix_e2e",), + ("providers",), + ("MODEL", "MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"), + "configured mixed-provider mappings must resolve consistently", + ), + FeatureCoverage( + "thinking_token_support", + "Thinking history, adaptive thinking, and redacted blocks are accepted", + "readme", + ( + "tests/contracts/test_stream_contracts.py", + "tests/providers/test_open_router.py", + ), + (), + ( + "test_provider_adaptive_thinking_history_e2e", + "test_provider_reasoning_tool_continuation_e2e", + "test_gemini_thought_signature_tool_continuation_e2e", + "test_claude_cli_adaptive_thinking_e2e", + "test_per_model_thinking_config_e2e", + ), + ("providers", "cli", "config"), + ("configured provider",), + "configured providers must not reject adaptive thinking payloads", + ), + FeatureCoverage( + "heuristic_tool_parser", + "Tool use and tool result continuation survive provider/client paths", + "readme", + ("tests/providers/test_parsers.py", "tests/contracts/test_stream_contracts.py"), + ("test_live_tool_use_when_configured_model_supports_tools",), + ( + "test_provider_interleaved_thinking_tool_e2e", + "test_provider_tool_result_continuation_e2e", + "test_gemini_thought_signature_tool_continuation_e2e", + "test_provider_reasoning_tool_continuation_e2e", + ), + ("tools", "providers"), + ("configured tool-capable provider",), + "tool-capable configured providers must emit or continue tool results", + ), + FeatureCoverage( + "request_optimization", + "Local request optimizations return product responses without providers", + "public_surface", + ( + "tests/api/test_optimization_handlers.py", + "tests/api/test_routes_optimizations.py", + ), + ("test_optimization_fast_paths_do_not_need_provider",), + ("test_api_request_optimizations_e2e",), + ("api",), + (), + "always runnable once the local smoke server starts", + ), + FeatureCoverage( + "smart_rate_limiting", + "Transient retries and disconnect cleanup preserve follow-up requests", + "public_surface", + ( + "tests/providers/test_provider_rate_limit.py", + "tests/providers/test_nvidia_nim_degraded_retry.py", + ), + ("test_client_disconnect_mid_stream_does_not_crash_server",), + ("test_provider_disconnect_e2e",), + ("rate_limit", "providers"), + ("configured provider",), + "upstream disconnects are skips only when classified upstream_unavailable", + ), + FeatureCoverage( + "provider_hot_swap", + "Provider config changes preserve active streams while new requests switch", + "public_surface", + ( + "tests/runtime/test_provider_manager.py", + "tests/api/test_response_streams.py", + "tests/api/test_admin.py", + ), + (), + ("test_provider_hot_swap_preserves_inflight_stream_e2e",), + ("api",), + (), + "credential-free local fake upstreams make the scenario always runnable", + ), + FeatureCoverage( + "discord_telegram_bot", + "Discord and Telegram product flows render progress and transcripts", + "readme", + ( + "tests/messaging/test_discord_platform.py", + "tests/messaging/test_telegram.py", + "tests/messaging/test_limiter.py", + "tests/messaging/test_platform_outbox.py", + "tests/runtime/test_application_runtime.py", + ), + ( + "test_telegram_bot_api_permissions", + "test_discord_bot_api_permissions", + ), + ( + "test_messaging_fake_full_flow_e2e", + "test_telegram_live_permissions_e2e", + "test_discord_live_permissions_e2e", + ), + ("messaging", "telegram", "discord"), + ("bot tokens/channels only for side-effectful live platform tests",), + "fake platform is required; real platforms skip without explicit env", + ), + FeatureCoverage( + "subagent_control", + "Task-like tool output is rendered and controlled as foreground work", + "public_surface", + ("tests/providers/test_subagent_interception.py",), + (), + ("test_messaging_subagent_control_e2e",), + ("messaging",), + (), + "fake platform exercises tool transcript behavior without spawning agents", + ), + FeatureCoverage( + "extensible_provider_platform_abcs", + "Provider and platform factories expose built-in extension points", + "public_surface", + ( + "tests/contracts/test_feature_manifest.py", + "tests/providers/test_provider_runtime.py", + ), + (), + ("test_provider_runtime_config_e2e", "test_platform_factory_e2e"), + ("extensibility",), + (), + "always runnable with isolated settings", + ), + FeatureCoverage( + "optional_authentication", + "Anthropic-style auth headers are enforced and accepted", + "readme", + ("tests/api/test_auth.py",), + ("test_auth_token_is_enforced_for_all_supported_header_shapes",), + ("test_api_auth_header_variants_e2e",), + ("auth",), + ("ANTHROPIC_AUTH_TOKEN",), + "product test starts an isolated token-protected server", + ), + FeatureCoverage( + "vscode_extension", + "VS Code protocol-shaped requests work against the proxy", + "readme", + ( + "tests/core/anthropic/test_models.py::test_messages_request_accepts_adaptive_thinking_type", + ), + ("test_vscode_and_jetbrains_shaped_requests",), + ("test_vscode_protocol_e2e",), + ("clients",), + ("configured provider",), + "extension source is external; protocol payload is covered here", + ), + FeatureCoverage( + "intellij_extension", + "JetBrains/ACP protocol-shaped requests work against the proxy", + "readme", + ( + "tests/core/anthropic/test_models.py::test_messages_request_accepts_adaptive_thinking_type", + ), + ("test_vscode_and_jetbrains_shaped_requests",), + ("test_jetbrains_protocol_e2e",), + ("clients",), + ("configured provider",), + "extension source is external; protocol payload is covered here", + ), + FeatureCoverage( + "voice_notes", + "Voice note intake, cancellation, and transcription backends work", + "readme", + ( + "tests/messaging/test_voice_handlers.py", + "tests/messaging/test_transcription.py", + ), + ("test_voice_transcription_backend_when_explicitly_enabled",), + ( + "test_voice_platform_fake_e2e", + "test_voice_local_backend_e2e", + "test_voice_nim_backend_e2e", + ), + ("messaging", "voice"), + ("VOICE_NOTE_ENABLED", "FCC_SMOKE_RUN_VOICE", "WHISPER_DEVICE"), + "fake cancellation is required; backend transcription is opt-in", + ), + FeatureCoverage( + "anthropic_api_routes", + "Messages, count_tokens, errors, and stop use Anthropic-compatible shapes", + "public_surface", + ("tests/api/test_api.py",), + ("test_probe_and_models_routes", "test_stop_endpoint_reports_no_messaging"), + ( + "test_api_basic_conversation_e2e", + "test_api_count_tokens_full_payload_e2e", + "test_api_error_shape_e2e", + "test_api_stop_e2e", + ), + ("api",), + ("configured provider for streaming messages",), + "route pings are prereqs; product route behavior is covered by E2E cases", + ), + FeatureCoverage( + "probe_routes", + "HEAD and OPTIONS compatibility probes are accepted", + "public_surface", + ("tests/api/test_api.py::test_probe_endpoints_return_204_with_allow_headers",), + ("test_probe_and_models_routes",), + (), + ("api",), + (), + "always runnable once the local smoke server starts", + "probe routes are compatibility prerequisites, not product behavior", + ), + FeatureCoverage( + "count_tokens_contract", + "Token counting accepts full Claude content payloads", + "public_surface", + ("tests/api/test_request_utils.py",), + ("test_count_tokens_accepts_thinking_tools_and_results",), + ("test_api_count_tokens_full_payload_e2e",), + ("api",), + (), + "always runnable once the local smoke server starts", + ), + FeatureCoverage( + "provider_proxy_timeout_config", + "Provider proxies and HTTP timeout settings reach provider config", + "public_surface", + ("tests/api/test_dependencies.py", "tests/providers/test_provider_runtime.py"), + (), + ("test_proxy_timeout_config_e2e",), + ("config",), + (), + "always runnable with isolated env files", + ), + FeatureCoverage( + "lmstudio_endpoint", + "LM Studio Messages and local no-key operation work when running", + "public_surface", + ("tests/providers/test_lmstudio.py",), + ("test_lmstudio_models_endpoint_when_available",), + ("test_lmstudio_messages_e2e",), + ("lmstudio",), + ("LM_STUDIO_BASE_URL with running LM Studio server",), + "skip when local upstream is unavailable", + ), + FeatureCoverage( + "llamacpp_endpoint", + "llama.cpp OpenAI Chat and local no-key operation work when running", + "public_surface", + ("tests/providers/test_llamacpp.py",), + ("test_llamacpp_models_endpoint_when_available",), + ("test_llamacpp_openai_chat_e2e",), + ("llamacpp",), + ("LLAMACPP_BASE_URL with running llama-server",), + "skip when local upstream is unavailable", + ), + FeatureCoverage( + "ollama_endpoint", + "Ollama OpenAI Chat and local no-key operation work when running", + "public_surface", + ("tests/providers/test_ollama.py",), + ("test_ollama_models_endpoint_when_available",), + ("test_ollama_openai_chat_e2e",), + ("ollama",), + ("OLLAMA_BASE_URL with running Ollama server",), + "skip when local upstream is unavailable", + ), + FeatureCoverage( + "package_cli_entrypoints", + "Installed package scripts scaffold config, report version, and start the server", + "public_surface", + ("tests/cli/test_entrypoints.py", "tests/core/test_version.py"), + ( + "test_fcc_init_scaffolds_user_config", + "test_free_claude_code_entrypoint_starts_server", + ), + ( + "test_entrypoint_init_e2e", + "test_entrypoint_server_e2e", + "test_entrypoint_version_e2e", + ), + ("cli",), + (), + "always runnable once uv project dependencies are available", + ), + FeatureCoverage( + "claude_cli_drop_in", + "Claude CLI can send adaptive thinking and tool-shaped history", + "public_surface", + ("tests/cli/test_cli.py",), + ("test_claude_cli_prompt_when_available",), + ( + "test_claude_cli_adaptive_thinking_e2e", + "test_claude_cli_multiturn_tool_protocol_e2e", + "test_nvidia_nim_cli_matrix_e2e", + "test_openrouter_free_cli_matrix_e2e", + ), + ("cli", "nvidia_nim_cli", "openrouter_free_cli"), + ( + "FCC_SMOKE_CLAUDE_BIN", + "configured provider", + "NVIDIA_NIM_API_KEY", + "OPENROUTER_API_KEY", + ), + "skip only when Claude CLI binary is absent", + ), + FeatureCoverage( + "messaging_commands", + "Messaging commands clear exact reply subtrees or whole managed chats", + "public_surface", + ( + "tests/messaging/test_handler.py", + "tests/messaging/test_handler_integration.py", + "tests/messaging/test_tree_ownership_concurrency.py", + ), + (), + ( + "test_messaging_commands_stop_clear_stats_e2e", + "test_reply_clear_uses_literal_platform_subtree_e2e", + "test_messaging_startup_notice_is_clearable_e2e", + "test_messaging_active_stop_uses_status_only_e2e", + "test_messaging_queued_scoped_cancel_e2e", + ), + ("messaging",), + (), + "required fake-platform product flow", + ), + FeatureCoverage( + "tree_threading", + "Reply-based branches fork sessions and stay scoped", + "public_surface", + ( + "tests/messaging/test_tree_queue.py", + "tests/messaging/test_tree_concurrency.py", + "tests/messaging/test_message_tree_transitions.py", + "tests/messaging/test_tree_ownership_concurrency.py", + ), + (), + ( + "test_tree_threading_e2e", + "test_messaging_queued_scoped_cancel_e2e", + "test_same_message_ids_are_isolated_by_chat_e2e", + ), + ("messaging",), + (), + "required fake-platform product flow", + ), + FeatureCoverage( + "restart_restore", + "Persisted tree state restores reply routing after restart", + "public_surface", + ("tests/messaging/test_restart_reply_restore.py",), + (), + ("test_restart_restore_and_session_persistence_e2e",), + ("messaging",), + (), + "required fake-platform persistence flow", + ), + FeatureCoverage( + "session_persistence", + "Session JSON preserves scoped trees and message logs", + "public_surface", + ("tests/messaging/test_session_store_edge_cases.py",), + (), + ("test_restart_restore_and_session_persistence_e2e",), + ("messaging",), + (), + "required fake-platform persistence flow", + ), + FeatureCoverage( + "config_env_precedence", + "FCC_ENV_FILE, dotenv, and process env precedence are deterministic", + "public_surface", + ("tests/config/test_config.py",), + (), + ("test_env_precedence_e2e",), + ("config",), + (), + "always runnable with isolated env files", + ), + FeatureCoverage( + "removed_env_migration", + "Removed thinking env vars are ignored without changing defaults", + "public_surface", + ("tests/config/test_config.py",), + (), + ("test_removed_env_migration_e2e",), + ("config",), + (), + "always runnable with isolated env files", + ), + FeatureCoverage( + "streaming_error_mapping", + "Canonical execution failures map to protocol-correct terminal output", + "public_surface", + ( + "tests/api/test_execution_failure_contract.py", + "tests/core/test_failure_protocol_mapping.py", + "tests/providers/test_execution_failure_boundary.py", + "tests/providers/test_failure_policy.py", + ), + (), + ( + "test_api_error_shape_e2e", + "test_provider_error_e2e", + "test_claude_cli_provider_error_e2e", + ), + ("api", "providers", "cli"), + ("configured provider for provider error scenario",), + "invalid request path is required; provider error path requires provider", + ), +) + + +def feature_ids(*, source: FeatureSource | None = None) -> set[str]: + """Return feature IDs covered by the inventory.""" + return { + feature.feature_id + for feature in FEATURE_INVENTORY + if source is None or feature.source == source + } diff --git a/smoke/lib/__init__.py b/smoke/lib/__init__.py new file mode 100644 index 0000000..5f127b6 --- /dev/null +++ b/smoke/lib/__init__.py @@ -0,0 +1 @@ +"""Shared helpers for local-only smoke tests.""" diff --git a/smoke/lib/child_process.py b/smoke/lib/child_process.py new file mode 100644 index 0000000..79e5dc3 --- /dev/null +++ b/smoke/lib/child_process.py @@ -0,0 +1,70 @@ +"""Child-process commands for smoke (avoid nested ``uv run`` on Windows). + +Nested ``uv run`` can try to refresh console scripts while they are locked +(``free-claude-code.exe`` in use), causing flaky smoke. The smoke runner is +already executed under the project environment (``uv run pytest``), so children +should use the same interpreter. +""" + +import subprocess +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path + + +def python_exe() -> str: + return sys.executable + + +def cmd_python_c(script: str) -> list[str]: + return [python_exe(), "-c", script] + + +def cmd_fcc_init() -> list[str]: + return [ + python_exe(), + "-c", + "from free_claude_code.cli.entrypoints import init; init()", + ] + + +def cmd_fcc_version() -> list[str]: + return [ + python_exe(), + "-c", + ( + "import sys; " + "sys.argv = ['fcc-server', '--version']; " + "from free_claude_code.cli.entrypoints import serve; serve()" + ), + ] + + +def cmd_free_claude_code_serve() -> list[str]: + return [ + python_exe(), + "-c", + "from free_claude_code.cli.entrypoints import serve; serve()", + ] + + +def run_captured_text( + command: Sequence[str], + *, + cwd: str | Path | None = None, + env: Mapping[str, str] | None = None, + timeout: float | None = None, + check: bool = False, +) -> subprocess.CompletedProcess[str]: + """Run a smoke child process with deterministic captured text decoding.""" + return subprocess.run( + list(command), + cwd=cwd, + env=env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=check, + ) diff --git a/smoke/lib/claude_cli_matrix.py b/smoke/lib/claude_cli_matrix.py new file mode 100644 index 0000000..3767372 --- /dev/null +++ b/smoke/lib/claude_cli_matrix.py @@ -0,0 +1,786 @@ +"""Claude Code CLI characterization helpers for provider smoke matrices.""" + +import json +import os +import re +import subprocess +import time +import uuid +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +from free_claude_code.cli.claude_env import build_claude_proxy_env +from smoke.lib.child_process import run_captured_text +from smoke.lib.config import ProviderModel, SmokeConfig, redacted +from smoke.lib.server import RunningServer + +REGRESSION_CLASSIFICATIONS = frozenset({"harness_bug", "product_failure"}) + +_HTTP_REGRESSION_PATTERNS = ( + r'POST /v1/messages[^"\n]* HTTP/1\.1" 4(?!01|03|04|08|09)\d\d', + r'POST /v1/messages[^"\n]* HTTP/1\.1" 5\d\d', +) +_UPSTREAM_UNAVAILABLE_MARKERS = ( + "upstream_unavailable", + "readtimeout", + "connecterror", + "connection refused", + "timed out", + "rate limit", + "overloaded", + "capacity", + "upstream provider", + "provider api request failed", + "httpstatuserror", +) +_HTTP_429_PATTERNS = ( + r'HTTP/1\.[01]" 429\b', + r"\bHTTP/1\.[01] 429\b", + r"\bstatus_code=429\b", + r"\bstatus[=:]\s*429\b", + r"\b429 Too Many Requests\b", +) +_MISSING_ENV_MARKERS = ( + "api key", + "not logged in", + "authentication", + "permission denied", +) +_EMPTY_MCP_CONFIG = '{"mcpServers":{}}' +_SUBAGENT_SYSTEM_PROMPT = ( + "You are a deterministic smoke-test coordinator. Use Agent when asked to " + "use a subagent." +) + + +@dataclass(frozen=True, slots=True) +class ClaudeCliRun: + command: tuple[str, ...] + returncode: int | None + stdout: str + stderr: str + duration_s: float + timed_out: bool = False + + @property + def combined_output(self) -> str: + return f"{self.stdout}\n{self.stderr}" + + +@dataclass(frozen=True, slots=True) +class CliMatrixOutcome: + model: str + full_model: str + source: str + feature: str + outcome: str + classification: str + duration_s: float + cli_returncode: int | None + token_evidence: dict[str, Any] + request_count: int + log_path: str + stdout_excerpt: str + stderr_excerpt: str + log_excerpt: str + + +def run_claude_cli( + *, + claude_bin: str, + server: RunningServer, + config: SmokeConfig, + cwd: Path, + prompt: str, + tools: str | None, + bare: bool = True, + pre_tool_args: tuple[str, ...] = (), + extra_args: tuple[str, ...] = (), + session_id: str | None = None, + resume_session_id: str | None = None, + no_session_persistence: bool = True, +) -> ClaudeCliRun: + """Run Claude Code CLI against the local smoke proxy.""" + cwd.mkdir(parents=True, exist_ok=True) + + cmd = list( + _build_claude_cli_command( + claude_bin=claude_bin, + prompt=prompt, + tools=tools, + bare=bare, + pre_tool_args=pre_tool_args, + extra_args=extra_args, + session_id=session_id, + resume_session_id=resume_session_id, + no_session_persistence=no_session_persistence, + ) + ) + + env = build_claude_proxy_env( + proxy_root_url=server.base_url, + auth_token=config.settings.anthropic_auth_token, + base_env=os.environ, + ) + env["TERM"] = "dumb" + env["NO_COLOR"] = "1" + env["PYTHONIOENCODING"] = "utf-8" + + started = time.monotonic() + try: + result = run_captured_text( + cmd, + cwd=cwd, + env=env, + timeout=config.timeout_s, + check=False, + ) + except subprocess.TimeoutExpired as exc: + return ClaudeCliRun( + command=tuple(cmd), + returncode=None, + stdout=_coerce_timeout_text(exc.stdout), + stderr=_coerce_timeout_text(exc.stderr), + duration_s=time.monotonic() - started, + timed_out=True, + ) + + return ClaudeCliRun( + command=tuple(cmd), + returncode=result.returncode, + stdout=_coerce_timeout_text(result.stdout), + stderr=_coerce_timeout_text(result.stderr), + duration_s=time.monotonic() - started, + ) + + +def _build_claude_cli_command( + *, + claude_bin: str, + prompt: str, + tools: str | None, + bare: bool = True, + pre_tool_args: tuple[str, ...] = (), + extra_args: tuple[str, ...] = (), + session_id: str | None = None, + resume_session_id: str | None = None, + no_session_persistence: bool = True, +) -> tuple[str, ...]: + cmd: list[str] = [claude_bin] + if bare: + cmd.append("--bare") + if resume_session_id: + cmd.extend(["--resume", resume_session_id]) + if session_id: + cmd.extend(["--session-id", session_id]) + cmd.extend( + [ + "--output-format", + "stream-json", + "--include-partial-messages", + "--verbose", + "--permission-mode", + "bypassPermissions", + "--dangerously-skip-permissions", + "--model", + "sonnet", + ] + ) + if no_session_persistence: + cmd.append("--no-session-persistence") + cmd.extend(pre_tool_args) + if tools is not None: + cmd.extend(["--tools", tools]) + if tools: + cmd.extend(["--allowedTools", tools]) + cmd.extend(extra_args) + cmd.extend(["-p", prompt]) + return tuple(cmd) + + +def run_cli_feature_probes( + *, + claude_bin: str, + server: RunningServer, + smoke_config: SmokeConfig, + provider_model: ProviderModel, + model_dir: Path, + marker_prefix: str, +) -> list[CliMatrixOutcome]: + return [ + _basic_text( + claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix + ), + _thinking( + claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix + ), + _tool_use_roundtrip( + claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix + ), + _interleaved_thinking_tool( + claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix + ), + _subagent_task( + claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix + ), + _compact_command( + claude_bin, server, smoke_config, provider_model, model_dir, marker_prefix + ), + ] + + +def read_log_offset(log_path: Path) -> int: + """Return the current text length of a smoke server log.""" + if not log_path.is_file(): + return 0 + return len(log_path.read_text(encoding="utf-8", errors="replace")) + + +def read_log_delta(log_path: Path, offset: int) -> str: + """Return smoke server log text written after ``offset``.""" + if not log_path.is_file(): + return "" + text = log_path.read_text(encoding="utf-8", errors="replace") + return text[offset:] + + +def token_evidence( + *, + feature: str, + marker: str, + run: ClaudeCliRun, + log_delta: str, +) -> dict[str, Any]: + """Collect compact evidence for a CLI feature probe.""" + combined = f"{run.combined_output}\n{log_delta}" + lower = combined.lower() + return { + "feature": feature, + "marker_present": bool(marker and marker in combined), + "thinking_delta_count": combined.count("thinking_delta"), + "tool_use_count": combined.count('"tool_use"'), + "tool_result_count": combined.count('"tool_result"'), + "agent_catalog_present": _tool_catalog_has(log_delta, "Agent"), + "agent_tool_count": _agent_tool_count(combined), + "agent_result_count": _agent_result_count(combined), + "task_tool_count": combined.count('"name": "Task"') + + combined.count('"name":"Task"'), + "run_in_background_false": "run_in_background" in combined and "false" in lower, + "compact_boundary": "compact_boundary" in combined, + "compact_metadata": "compact_metadata" in combined, + "http_422": 'HTTP/1.1" 422' in combined, + "http_500": bool(re.search(r'HTTP/1\.1" 5\d\d', combined)), + "timed_out": run.timed_out, + } + + +def classify_probe( + *, + run: ClaudeCliRun, + log_delta: str, + marker: str, + requires_tool_result: bool = False, + requires_agent: bool = False, + requires_task: bool = False, + requires_compact: bool = False, +) -> tuple[str, str]: + """Classify a probe without failing compatibility characterization failures.""" + combined = f"{run.combined_output}\n{log_delta}" + lower = combined.lower() + + if _has_proxy_regression(log_delta): + return "failed", "product_failure" + if run.returncode != 0 and any( + marker_text in lower for marker_text in _MISSING_ENV_MARKERS + ): + return "skipped", "missing_env" + if run.timed_out: + return "failed", "probe_timeout" + if requires_agent and not _tool_catalog_has(log_delta, "Agent"): + return "failed", "harness_bug" + + marker_ok = not marker or marker in combined + tool_ok = not requires_tool_result or '"tool_result"' in combined + agent_ok = not requires_agent or ( + _agent_tool_count(combined) > 0 and _agent_result_count(combined) > 0 + ) + task_ok = not requires_task or ( + ('"name": "Task"' in combined or '"name":"Task"' in combined) + and "run_in_background" in combined + and "false" in lower + ) + compact_ok = not requires_compact or ( + "compact_boundary" in combined + or "compact_metadata" in combined + or "/compact" in combined + or "compact" in lower + ) + cli_ok = run.returncode == 0 + + if cli_ok and marker_ok and tool_ok and agent_ok and task_ok and compact_ok: + return "passed", "passed" + if _has_upstream_unavailable_text(combined): + return "failed", "upstream_unavailable" + if not _has_proxy_request(log_delta): + return "failed", "harness_bug" + return "failed", "model_feature_failure" + + +def make_outcome( + *, + model: str, + full_model: str, + source: str, + feature: str, + marker: str, + run: ClaudeCliRun, + log_delta: str, + log_path: Path, + requires_tool_result: bool = False, + requires_agent: bool = False, + requires_task: bool = False, + requires_compact: bool = False, +) -> CliMatrixOutcome: + """Build one report outcome from a CLI run and its server log delta.""" + outcome, classification = classify_probe( + run=run, + log_delta=log_delta, + marker=marker, + requires_tool_result=requires_tool_result, + requires_agent=requires_agent, + requires_task=requires_task, + requires_compact=requires_compact, + ) + evidence = token_evidence( + feature=feature, + marker=marker, + run=run, + log_delta=log_delta, + ) + return CliMatrixOutcome( + model=model, + full_model=full_model, + source=source, + feature=feature, + outcome=outcome, + classification=classification, + duration_s=round(run.duration_s, 3), + cli_returncode=run.returncode, + token_evidence=evidence, + request_count=_request_count(log_delta), + log_path=str(log_path), + stdout_excerpt=_excerpt(run.stdout), + stderr_excerpt=_excerpt(run.stderr), + log_excerpt=_excerpt(log_delta), + ) + + +def write_matrix_report( + config: SmokeConfig, + outcomes: list[CliMatrixOutcome], + *, + target: str, + filename_prefix: str, +) -> Path: + """Write a Claude CLI compatibility matrix report.""" + config.results_dir.mkdir(parents=True, exist_ok=True) + path = ( + config.results_dir + / f"{filename_prefix}-matrix-{config.worker_id}-{int(time.time())}.json" + ) + payload = { + "started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "worker_id": config.worker_id, + "target": target, + "models": sorted({outcome.full_model for outcome in outcomes}), + "outcomes": [asdict(outcome) for outcome in outcomes], + } + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + return path + + +def regression_failures(outcomes: list[CliMatrixOutcome]) -> list[str]: + """Return report lines for classifications that should fail pytest.""" + return [ + f"{outcome.full_model} {outcome.feature}: {outcome.classification}" + for outcome in outcomes + if outcome.classification in REGRESSION_CLASSIFICATIONS + ] + + +def _basic_text( + claude_bin: str, + server: RunningServer, + smoke_config: SmokeConfig, + provider_model: ProviderModel, + model_dir: Path, + marker_prefix: str, +) -> CliMatrixOutcome: + marker = _marker(marker_prefix, "BASIC") + return _run_probe( + claude_bin=claude_bin, + server=server, + smoke_config=smoke_config, + provider_model=provider_model, + workspace=model_dir / "basic_text", + feature="basic_text", + marker=marker, + prompt=f"Reply with exactly {marker} and no other text.", + tools="", + ) + + +def _thinking( + claude_bin: str, + server: RunningServer, + smoke_config: SmokeConfig, + provider_model: ProviderModel, + model_dir: Path, + marker_prefix: str, +) -> CliMatrixOutcome: + marker = _marker(marker_prefix, "THINK") + return _run_probe( + claude_bin=claude_bin, + server=server, + smoke_config=smoke_config, + provider_model=provider_model, + workspace=model_dir / "thinking", + feature="thinking", + marker=marker, + prompt=( + "Think privately about the request, then reply with exactly " + f"{marker} and no other text." + ), + tools="", + extra_args=("--effort", "high"), + ) + + +def _tool_use_roundtrip( + claude_bin: str, + server: RunningServer, + smoke_config: SmokeConfig, + provider_model: ProviderModel, + model_dir: Path, + marker_prefix: str, +) -> CliMatrixOutcome: + marker = _marker(marker_prefix, "TOOL") + workspace = model_dir / "tool_use_roundtrip" + (workspace / "smoke-read.txt").parent.mkdir(parents=True, exist_ok=True) + (workspace / "smoke-read.txt").write_text(marker, encoding="utf-8") + return _run_probe( + claude_bin=claude_bin, + server=server, + smoke_config=smoke_config, + provider_model=provider_model, + workspace=workspace, + feature="tool_use_roundtrip", + marker=marker, + prompt=( + "Use the Read tool to read smoke-read.txt. Reply with exactly the " + "secret token from that file and no other text." + ), + tools="Read", + requires_tool_result=True, + ) + + +def _interleaved_thinking_tool( + claude_bin: str, + server: RunningServer, + smoke_config: SmokeConfig, + provider_model: ProviderModel, + model_dir: Path, + marker_prefix: str, +) -> CliMatrixOutcome: + marker = _marker(marker_prefix, "INTERLEAVED") + workspace = model_dir / "interleaved_thinking_tool" + (workspace / "smoke-interleaved.txt").parent.mkdir(parents=True, exist_ok=True) + (workspace / "smoke-interleaved.txt").write_text(marker, encoding="utf-8") + return _run_probe( + claude_bin=claude_bin, + server=server, + smoke_config=smoke_config, + provider_model=provider_model, + workspace=workspace, + feature="interleaved_thinking_tool", + marker=marker, + prompt=( + "Think privately, use Read on smoke-interleaved.txt, then reply with " + "exactly the secret token from that file and no other text." + ), + tools="Read", + extra_args=("--effort", "high"), + requires_tool_result=True, + ) + + +def _subagent_task( + claude_bin: str, + server: RunningServer, + smoke_config: SmokeConfig, + provider_model: ProviderModel, + model_dir: Path, + marker_prefix: str, +) -> CliMatrixOutcome: + marker = _marker(marker_prefix, "TASK") + workspace = model_dir / "subagent_task" + (workspace / "smoke-subagent.txt").parent.mkdir(parents=True, exist_ok=True) + (workspace / "smoke-subagent.txt").write_text(marker, encoding="utf-8") + agents = json.dumps( + { + "smoke_reader": { + "description": "Reads one requested file and returns its token.", + "prompt": ( + "Read the requested file with Read and return only the token " + "inside it." + ), + "tools": ["Read"], + "permissionMode": "bypassPermissions", + "background": False, + } + } + ) + bare, tools, pre_tool_args, extra_args = _subagent_probe_options(agents) + return _run_probe( + claude_bin=claude_bin, + server=server, + smoke_config=smoke_config, + provider_model=provider_model, + workspace=workspace, + feature="subagent_task", + marker=marker, + prompt=( + "Use the smoke_reader subagent to read smoke-subagent.txt. After the " + "first agent result, reply with exactly the token and stop. Do not " + "call any other tools." + ), + tools=tools, + bare=bare, + pre_tool_args=pre_tool_args, + extra_args=extra_args, + requires_tool_result=True, + requires_agent=True, + ) + + +def _subagent_probe_options( + agents: str, +) -> tuple[bool, str, tuple[str, ...], tuple[str, ...]]: + return ( + False, + "Agent,Read", + ( + "--setting-sources", + "local", + "--strict-mcp-config", + "--mcp-config", + _EMPTY_MCP_CONFIG, + "--system-prompt", + _SUBAGENT_SYSTEM_PROMPT, + ), + ("--agents", agents), + ) + + +def _compact_command( + claude_bin: str, + server: RunningServer, + smoke_config: SmokeConfig, + provider_model: ProviderModel, + model_dir: Path, + marker_prefix: str, +) -> CliMatrixOutcome: + marker = _marker(marker_prefix, "COMPACT") + workspace = model_dir / "compact_command" + session_id = str(uuid.uuid4()) + offset = read_log_offset(server.log_path) + first = run_claude_cli( + claude_bin=claude_bin, + server=server, + config=smoke_config, + cwd=workspace, + prompt=f"Remember this smoke token: {marker}. Reply with exactly {marker}.", + tools="", + session_id=session_id, + no_session_persistence=False, + ) + second = run_claude_cli( + claude_bin=claude_bin, + server=server, + config=smoke_config, + cwd=workspace, + prompt=f"/compact preserve {marker}", + tools="", + resume_session_id=session_id, + no_session_persistence=False, + ) + log_delta = read_log_delta(server.log_path, offset) + run = ClaudeCliRun( + command=(*first.command, "&&", *second.command), + returncode=second.returncode if first.returncode == 0 else first.returncode, + stdout=f"{first.stdout}\n{second.stdout}", + stderr=f"{first.stderr}\n{second.stderr}", + duration_s=first.duration_s + second.duration_s, + timed_out=first.timed_out or second.timed_out, + ) + return make_outcome( + model=provider_model.model_name, + full_model=provider_model.full_model, + source=provider_model.source, + feature="compact_command", + marker="", + run=run, + log_delta=log_delta, + log_path=server.log_path, + requires_compact=True, + ) + + +def _run_probe( + *, + claude_bin: str, + server: RunningServer, + smoke_config: SmokeConfig, + provider_model: ProviderModel, + workspace: Path, + feature: str, + marker: str, + prompt: str, + tools: str | None, + bare: bool = True, + pre_tool_args: tuple[str, ...] = (), + extra_args: tuple[str, ...] = (), + requires_tool_result: bool = False, + requires_agent: bool = False, + requires_task: bool = False, +) -> CliMatrixOutcome: + offset = read_log_offset(server.log_path) + run = run_claude_cli( + claude_bin=claude_bin, + server=server, + config=smoke_config, + cwd=workspace, + prompt=prompt, + tools=tools, + bare=bare, + pre_tool_args=pre_tool_args, + extra_args=extra_args, + ) + log_delta = read_log_delta(server.log_path, offset) + return make_outcome( + model=provider_model.model_name, + full_model=provider_model.full_model, + source=provider_model.source, + feature=feature, + marker=marker, + run=run, + log_delta=log_delta, + log_path=server.log_path, + requires_tool_result=requires_tool_result, + requires_agent=requires_agent, + requires_task=requires_task, + ) + + +def _has_proxy_regression(log_delta: str) -> bool: + if "CREATE_MESSAGE_ERROR" in log_delta: + return True + return any(re.search(pattern, log_delta) for pattern in _HTTP_REGRESSION_PATTERNS) + + +def _has_proxy_request(log_delta: str) -> bool: + return ( + "POST /v1/messages" in log_delta + or "API_REQUEST:" in log_delta + or '"event": "free_claude_code.api.request.received"' in log_delta + or ( + '"http_method": "POST"' in log_delta + and '"http_path": "/v1/messages"' in log_delta + ) + ) + + +def _tool_catalog_has(log_delta: str, tool_name: str) -> bool: + catalog = _first_tool_catalog(log_delta) + return ( + f"'name': '{tool_name}'" in catalog + or f'"name": "{tool_name}"' in catalog + or f'"name":"{tool_name}"' in catalog + ) + + +def _first_tool_catalog(log_delta: str) -> str: + for line in log_delta.splitlines(): + if "FULL_PAYLOAD" not in line: + continue + single_index = line.find("'tools': [") + double_index = line.find('"tools": [') + if single_index == -1 and double_index == -1: + continue + start = single_index if single_index != -1 else double_index + end_candidates = [ + index + for marker in ("'tool_choice'", '"tool_choice"', "'thinking'", '"thinking"') + if (index := line.find(marker, start)) != -1 + ] + end = min(end_candidates) if end_candidates else len(line) + return line[start:end] + return "" + + +def _agent_tool_count(text: str) -> int: + return ( + text.count('"name": "Agent"') + + text.count('"name":"Agent"') + + len( + re.findall( + r"'type': 'tool_use'[^}\n]+?'name': 'Agent'", + text, + flags=re.DOTALL, + ) + ) + ) + + +def _agent_result_count(text: str) -> int: + return text.count("agentId:") + text.count('"agentId"') + text.count("'agentId'") + + +def _has_upstream_unavailable_text(text: str) -> bool: + lower = text.lower() + if any(marker_text in lower for marker_text in _UPSTREAM_UNAVAILABLE_MARKERS): + return True + return any( + re.search(pattern, text, flags=re.IGNORECASE) for pattern in _HTTP_429_PATTERNS + ) + + +def _request_count(log_delta: str) -> int: + access_log_count = log_delta.count("POST /v1/messages") + service_log_count = log_delta.count("API_REQUEST:") + structured_log_count = log_delta.count( + '"event": "free_claude_code.api.request.received"' + ) + return max(access_log_count, service_log_count, structured_log_count) + + +def _marker(scope: str, prefix: str) -> str: + return f"FCC_{scope}_{prefix}_{uuid.uuid4().hex[:8].upper()}" + + +def _excerpt(value: str | None, *, max_chars: int = 2400) -> str: + if value is None: + value = "" + if len(value) <= max_chars: + return redacted(value) + return redacted(value[-max_chars:]) + + +def _coerce_timeout_text(value: str | bytes | None) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value diff --git a/smoke/lib/config.py b/smoke/lib/config.py new file mode 100644 index 0000000..4165340 --- /dev/null +++ b/smoke/lib/config.py @@ -0,0 +1,445 @@ +"""Smoke-suite configuration loaded from the real developer environment.""" + +import os +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +from free_claude_code.config.model_refs import parse_model_name, parse_provider_type +from free_claude_code.config.provider_catalog import ( + PROVIDER_CATALOG, + SUPPORTED_PROVIDER_IDS, +) +from free_claude_code.config.settings import Settings, get_settings + +DEFAULT_TARGETS = frozenset( + { + "api", + "auth", + "cli", + "clients", + "config", + "extensibility", + "llamacpp", + "lmstudio", + "messaging", + "ollama", + "providers", + "rate_limit", + "tools", + } +) +SIDE_EFFECT_TARGETS = frozenset({"discord", "telegram", "voice"}) +OPT_IN_TARGETS = frozenset({"nvidia_nim_cli", "openrouter_free_cli"}) +ALL_TARGETS = DEFAULT_TARGETS | SIDE_EFFECT_TARGETS | OPT_IN_TARGETS +TARGET_ALIASES = { + "contract": "api", + "nim_cli": "nvidia_nim_cli", + "openrouter_cli": "openrouter_free_cli", + "openrouter_free": "openrouter_free_cli", + "optimizations": "api", + "thinking": "providers", + "vscode": "clients", +} +SECRET_KEY_PARTS = ("KEY", "TOKEN", "SECRET", "WEBHOOK", "AUTH") + +PROVIDER_SMOKE_DEFAULT_MODELS: dict[str, str] = { + "nvidia_nim": "nvidia_nim/nvidia/nemotron-3-super-120b-a12b", + "open_router": "open_router/moonshotai/kimi-k2.6:free", + "mistral": "mistral/devstral-small-latest", + "mistral_codestral": "mistral_codestral/codestral-latest", + "deepseek": "deepseek/deepseek-v4-pro", + "lmstudio": "lmstudio/local-model", + "llamacpp": "llamacpp/local-model", + "ollama": "ollama/llama3.1", + "wafer": "wafer/DeepSeek-V4-Pro", + "minimax": "minimax/MiniMax-M3", + "opencode": "opencode/gpt-5.3-codex", + "opencode_go": "opencode_go/minimax-m2.7", + "vercel": "vercel/openai/gpt-5.5", + "huggingface": "huggingface/openai/gpt-oss-120b:fastest", + "cohere": "cohere/command-a-plus-05-2026", + "github_models": "github_models/openai/gpt-4.1", + "zai": "zai/glm-5.2", + "gemini": "gemini/models/gemini-3.1-flash-lite", + "groq": "groq/llama-3.3-70b-versatile", + "sambanova": "sambanova/Meta-Llama-3.3-70B-Instruct", + "cerebras": "cerebras/llama3.1-8b", + "cloudflare": "cloudflare/@cf/moonshotai/kimi-k2.6", +} +MISTRAL_REASONING_SMOKE_DEFAULT_MODEL = "mistral/mistral-medium-3-5" + +NVIDIA_NIM_CLI_DEFAULT_MODELS: tuple[str, ...] = ( + "z-ai/glm-5.2", + "moonshotai/kimi-k2.6", + "minimaxai/minimax-m2.7", + "nvidia/nemotron-3-super-120b-a12b", + "deepseek-ai/deepseek-v4-pro", + "deepseek-ai/deepseek-v4-flash", +) + +OPENROUTER_FREE_CLI_DEFAULT_MODELS: tuple[str, ...] = ( + "nvidia/nemotron-3-super-120b-a12b:free", + "openai/gpt-oss-120b:free", + "poolside/laguna-m.1:free", +) + + +TARGET_REQUIRED_ENV: dict[str, tuple[str, ...]] = { + "api": (), + "auth": (), + "cli": ("FCC_SMOKE_CLAUDE_BIN", "configured provider for Claude CLI prompt"), + "clients": (), + "config": (), + "extensibility": (), + "messaging": (), + "providers": ("configured provider credentials/endpoints or FCC_SMOKE_MODEL_*",), + "rate_limit": ("configured provider model",), + "tools": ("configured tool-capable provider model",), + "lmstudio": ("LM_STUDIO_BASE_URL with a running LM Studio server",), + "llamacpp": ("LLAMACPP_BASE_URL with a running llama-server",), + "ollama": ("OLLAMA_BASE_URL with a running Ollama server",), + "nvidia_nim_cli": ( + "NVIDIA_NIM_API_KEY", + "FCC_SMOKE_CLAUDE_BIN or claude on PATH", + ), + "openrouter_free_cli": ( + "OPENROUTER_API_KEY", + "FCC_SMOKE_CLAUDE_BIN or claude on PATH", + ), + "telegram": ( + "TELEGRAM_BOT_TOKEN", + "ALLOWED_TELEGRAM_USER_ID or FCC_SMOKE_TELEGRAM_CHAT_ID", + ), + "discord": ( + "DISCORD_BOT_TOKEN", + "ALLOWED_DISCORD_CHANNELS or FCC_SMOKE_DISCORD_CHANNEL_ID", + ), + "voice": ("VOICE_NOTE_ENABLED=true", "FCC_SMOKE_RUN_VOICE=1"), +} + + +@dataclass(frozen=True, slots=True) +class ProviderModel: + provider: str + full_model: str + source: str + + @property + def model_name(self) -> str: + return parse_model_name(self.full_model) + + +@dataclass(frozen=True, slots=True) +class SmokeConfig: + root: Path + results_dir: Path + live: bool + interactive: bool + targets: frozenset[str] + provider_matrix: frozenset[str] + timeout_s: float + prompt: str + claude_bin: str + worker_id: str + settings: Settings + + @classmethod + def load(cls) -> SmokeConfig: + root = Path(__file__).resolve().parents[2] + get_settings.cache_clear() + settings = get_settings() + return cls( + root=root, + results_dir=root / ".smoke-results", + live=os.getenv("FCC_LIVE_SMOKE") == "1", + interactive=os.getenv("FCC_SMOKE_INTERACTIVE") == "1", + targets=_parse_targets(os.getenv("FCC_SMOKE_TARGETS")), + provider_matrix=_parse_csv(os.getenv("FCC_SMOKE_PROVIDER_MATRIX")), + timeout_s=float(os.getenv("FCC_SMOKE_TIMEOUT_S", "45")), + prompt=os.getenv("FCC_SMOKE_PROMPT", "Reply with exactly: FCC_SMOKE_PONG"), + claude_bin=os.getenv("FCC_SMOKE_CLAUDE_BIN", "claude"), + worker_id=os.getenv("PYTEST_XDIST_WORKER", "main"), + settings=settings, + ) + + def target_enabled(self, *names: str) -> bool: + return any(name in self.targets for name in names) + + def provider_models(self) -> list[ProviderModel]: + candidates = ( + ("MODEL", self.settings.model), + ("MODEL_OPUS", self.settings.model_opus), + ("MODEL_SONNET", self.settings.model_sonnet), + ("MODEL_HAIKU", self.settings.model_haiku), + ) + seen: set[str] = set() + models: list[ProviderModel] = [] + for source, model in candidates: + if not model or model in seen: + continue + provider = parse_provider_type(model) + if self.provider_matrix and provider not in self.provider_matrix: + continue + if not self.has_provider_configuration(provider): + continue + seen.add(model) + models.append( + ProviderModel(provider=provider, full_model=model, source=source) + ) + return models + + def provider_smoke_models(self) -> list[ProviderModel]: + """Return one smoke model per configured provider, independent of MODEL_*.""" + models: list[ProviderModel] = [] + mapped_providers = {model.provider for model in self.provider_models()} + for provider in SUPPORTED_PROVIDER_IDS: + if self.provider_matrix and provider not in self.provider_matrix: + continue + if not self.has_provider_configuration(provider): + continue + if not self._include_provider_in_smoke(provider, mapped_providers): + continue + full_model, source = _provider_smoke_model(provider) + models.append( + ProviderModel(provider=provider, full_model=full_model, source=source) + ) + return models + + def nvidia_nim_cli_models(self) -> list[ProviderModel]: + """Return the NVIDIA NIM models for Claude Code CLI characterization.""" + return [ + ProviderModel(provider="nvidia_nim", full_model=full_model, source=source) + for full_model, source in nvidia_nim_cli_model_refs().items() + ] + + def openrouter_free_cli_models(self) -> list[ProviderModel]: + """Return OpenRouter free models for Claude Code CLI characterization.""" + return [ + ProviderModel(provider="open_router", full_model=full_model, source=source) + for full_model, source in openrouter_free_cli_model_refs().items() + ] + + def mistral_reasoning_smoke_model(self) -> ProviderModel | None: + """Return a Mistral model expected to accept native reasoning input.""" + if self.provider_matrix and "mistral" not in self.provider_matrix: + return None + if not self.has_provider_configuration("mistral"): + return None + override_env = "FCC_SMOKE_MODEL_MISTRAL_REASONING" + if override := os.getenv(override_env): + full_model = _normalize_provider_model("mistral", override) + source = override_env + else: + full_model = MISTRAL_REASONING_SMOKE_DEFAULT_MODEL + source = "mistral_reasoning_default" + return ProviderModel(provider="mistral", full_model=full_model, source=source) + + def _include_provider_in_smoke( + self, provider: str, mapped_providers: set[str] + ) -> bool: + descriptor = PROVIDER_CATALOG[provider] + if not descriptor.local: + return True + if provider in mapped_providers: + return True + if self.provider_matrix and provider in self.provider_matrix: + return True + return bool(os.getenv(f"FCC_SMOKE_MODEL_{provider.upper()}")) + + def has_provider_configuration(self, provider: str) -> bool: + if provider == "nvidia_nim": + return bool(self.settings.nvidia_nim_api_key.strip()) + if provider == "open_router": + return bool(self.settings.open_router_api_key.strip()) + if provider == "mistral": + return bool(self.settings.mistral_api_key.strip()) + if provider == "mistral_codestral": + return bool(self.settings.codestral_api_key.strip()) + if provider == "deepseek": + return bool(self.settings.deepseek_api_key.strip()) + if provider == "kimi": + return bool(self.settings.kimi_api_key.strip()) + if provider == "lmstudio": + return bool(self.settings.lm_studio_base_url.strip()) + if provider == "llamacpp": + return bool(self.settings.llamacpp_base_url.strip()) + if provider == "ollama": + return bool(self.settings.ollama_base_url.strip()) + if provider == "wafer": + return bool(self.settings.wafer_api_key.strip()) + if provider == "minimax": + return bool(self.settings.minimax_api_key.strip()) + if provider == "fireworks": + return bool(self.settings.fireworks_api_key.strip()) + if provider == "opencode": + return bool(self.settings.opencode_api_key.strip()) + if provider == "opencode_go": + return bool(self.settings.opencode_api_key.strip()) + if provider == "vercel": + return bool(self.settings.vercel_ai_gateway_api_key.strip()) + if provider == "huggingface": + return bool(self.settings.huggingface_api_key.strip()) + if provider == "cohere": + return bool(self.settings.cohere_api_key.strip()) + if provider == "github_models": + return bool(self.settings.github_models_token.strip()) + if provider == "zai": + return bool(self.settings.zai_api_key.strip()) + if provider == "gemini": + return bool(self.settings.gemini_api_key.strip()) + if provider == "groq": + return bool(self.settings.groq_api_key.strip()) + if provider == "sambanova": + return bool(self.settings.sambanova_api_key.strip()) + if provider == "cerebras": + return bool(self.settings.cerebras_api_key.strip()) + if provider == "cloudflare": + return bool( + self.settings.cloudflare_api_token.strip() + and self.settings.cloudflare_account_id.strip() + ) + return False + + +def _parse_csv(raw: str | None) -> frozenset[str]: + if not raw: + return frozenset() + return frozenset(part.strip() for part in raw.split(",") if part.strip()) + + +def _parse_csv_ordered(raw: str | None) -> tuple[str, ...]: + if not raw: + return () + return tuple(part.strip() for part in raw.split(",") if part.strip()) + + +def _parse_targets(raw: str | None) -> frozenset[str]: + if not raw: + return DEFAULT_TARGETS + parsed = _parse_csv(raw) + if "all" in parsed: + return ALL_TARGETS + return frozenset(TARGET_ALIASES.get(target, target) for target in parsed) + + +def _provider_smoke_model(provider: str) -> tuple[str, str]: + override_env = f"FCC_SMOKE_MODEL_{provider.upper()}" + if override := os.getenv(override_env): + return _normalize_provider_model(provider, override), override_env + + default = PROVIDER_SMOKE_DEFAULT_MODELS.get(provider) + if default is None: + descriptor = PROVIDER_CATALOG[provider] + default = f"{descriptor.provider_id}/smoke-default" + return default, "provider_default" + + +def _normalize_provider_model(provider: str, raw_model: str) -> str: + model = raw_model.strip() + if not model: + msg = f"FCC_SMOKE_MODEL_{provider.upper()} must not be empty" + raise ValueError(msg) + if "/" not in model: + return f"{provider}/{model}" + prefix = parse_provider_type(model) + if prefix == provider: + return model + if prefix in SUPPORTED_PROVIDER_IDS: + msg = ( + f"FCC_SMOKE_MODEL_{provider.upper()} must use provider prefix " + f"{provider!r}, got {model!r}" + ) + raise ValueError(msg) + return f"{provider}/{model}" + + +def nvidia_nim_cli_model_refs( + env: Mapping[str, str] | None = None, +) -> dict[str, str]: + """Return normalized NIM CLI matrix model refs in deterministic order. + + Values are returned as ``full_model -> source`` so callers can preserve both + de-duplicated order and provenance in reports. + """ + source = env if env is not None else os.environ + explicit_models = _parse_csv_ordered(source.get("FCC_SMOKE_NIM_MODELS")) + extra_models = _parse_csv_ordered(source.get("FCC_SMOKE_NIM_EXTRA_MODELS")) + + if "FCC_SMOKE_NIM_MODELS" in source and not explicit_models: + raise ValueError("FCC_SMOKE_NIM_MODELS must list at least one model") + + models: list[tuple[str, str]] = [] + base_models = explicit_models or NVIDIA_NIM_CLI_DEFAULT_MODELS + base_source = ( + "FCC_SMOKE_NIM_MODELS" if explicit_models else "nvidia_nim_cli_default" + ) + models.extend((model, base_source) for model in base_models) + models.extend((model, "FCC_SMOKE_NIM_EXTRA_MODELS") for model in extra_models) + + normalized: dict[str, str] = {} + for raw_model, model_source in models: + full_model = _normalize_provider_model("nvidia_nim", raw_model) + normalized.setdefault(full_model, model_source) + return normalized + + +def openrouter_free_cli_model_refs( + env: Mapping[str, str] | None = None, +) -> dict[str, str]: + """Return normalized OpenRouter free CLI matrix model refs in deterministic order.""" + source = env if env is not None else os.environ + explicit_models = _parse_csv_ordered(source.get("FCC_SMOKE_OPENROUTER_FREE_MODELS")) + extra_models = _parse_csv_ordered( + source.get("FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS") + ) + + if "FCC_SMOKE_OPENROUTER_FREE_MODELS" in source and not explicit_models: + raise ValueError( + "FCC_SMOKE_OPENROUTER_FREE_MODELS must list at least one model" + ) + + models: list[tuple[str, str]] = [] + base_models = explicit_models or OPENROUTER_FREE_CLI_DEFAULT_MODELS + base_source = ( + "FCC_SMOKE_OPENROUTER_FREE_MODELS" + if explicit_models + else "openrouter_free_cli_default" + ) + models.extend((model, base_source) for model in base_models) + models.extend( + (model, "FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS") for model in extra_models + ) + + normalized: dict[str, str] = {} + for raw_model, model_source in models: + full_model = _normalize_provider_model("open_router", raw_model) + normalized.setdefault(full_model, model_source) + return normalized + + +def auth_headers(token: str | None = None) -> dict[str, str]: + settings = get_settings() + resolved = token if token is not None else settings.anthropic_auth_token + headers = { + "anthropic-version": "2023-06-01", + "content-type": "application/json", + } + if resolved: + headers["x-api-key"] = resolved + return headers + + +def redacted(value: str, env: Mapping[str, str] | None = None) -> str: + """Redact known secrets from a string before writing smoke artifacts.""" + if not value: + return value + + source = env if env is not None else os.environ + result = value + for key, secret in source.items(): + if not secret or len(secret) < 4: + continue + if any(part in key.upper() for part in SECRET_KEY_PARTS): + result = result.replace(secret, f"") + return result diff --git a/smoke/lib/e2e.py b/smoke/lib/e2e.py new file mode 100644 index 0000000..a366a7e --- /dev/null +++ b/smoke/lib/e2e.py @@ -0,0 +1,756 @@ +"""Reusable product E2E smoke drivers.""" + +import asyncio +import json +import os +import subprocess +import time +import uuid +import wave +from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from free_claude_code.cli.claude_env import build_claude_proxy_env +from free_claude_code.config.provider_catalog import SUPPORTED_PROVIDER_IDS +from free_claude_code.core.anthropic.stream_contracts import ( + SSEEvent, + assert_anthropic_stream_contract, + event_index, + has_tool_use, + parse_sse_lines, + text_content, +) +from free_claude_code.messaging.models import IncomingMessage, MessageScope +from free_claude_code.messaging.session import SessionStore +from free_claude_code.messaging.voice import VoiceCancellationResult +from free_claude_code.messaging.workflow import MessagingWorkflow +from smoke.lib.child_process import run_captured_text +from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers +from smoke.lib.server import RunningServer, start_server +from smoke.lib.skips import fail_missing_env + + +@dataclass(slots=True) +class ConversationTurn: + request: dict[str, Any] + events: list[SSEEvent] + + @property + def assistant_content(self) -> list[dict[str, Any]]: + return assistant_content_from_events(self.events) + + @property + def text(self) -> str: + return text_content(self.events) + + +class SmokeServerDriver: + """Start a local proxy server for a product scenario.""" + + def __init__( + self, + config: SmokeConfig, + *, + name: str, + env_overrides: dict[str, str] | None = None, + command: list[str] | None = None, + ) -> None: + self.config = config + self.name = name + self.env_overrides = env_overrides + self.command = command + + @contextmanager + def run(self) -> Iterator[RunningServer]: + with start_server( + self.config, + env_overrides=self.env_overrides, + command=self.command, + name=self.name, + ) as server: + yield server + + +class ConversationDriver: + """Drive multi-turn Anthropic-compatible conversations through the server.""" + + def __init__(self, server: RunningServer, config: SmokeConfig) -> None: + self.server = server + self.config = config + self.messages: list[dict[str, Any]] = [] + self.turns: list[ConversationTurn] = [] + + def ask( + self, + text: str, + *, + model: str = "fcc-smoke-default", + max_tokens: int = 256, + extra: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + append_assistant: bool = True, + ) -> ConversationTurn: + self.messages.append({"role": "user", "content": text}) + payload = { + "model": model, + "max_tokens": max_tokens, + "messages": list(self.messages), + } + if extra: + payload.update(extra) + turn = self.stream(payload, headers=headers) + if append_assistant: + self.messages.append( + {"role": "assistant", "content": turn.assistant_content or turn.text} + ) + return turn + + def stream( + self, + payload: dict[str, Any], + *, + headers: dict[str, str] | None = None, + ) -> ConversationTurn: + request_headers = headers or auth_headers() + with httpx.stream( + "POST", + f"{self.server.base_url}/v1/messages", + headers=request_headers, + json=payload, + timeout=self.config.timeout_s, + ) as response: + if response.status_code != 200: + body = response.read().decode("utf-8", errors="replace") + raise AssertionError( + f"stream request failed: HTTP {response.status_code} {body[:1000]}" + ) + events = parse_sse_lines(response.iter_lines()) + assert_anthropic_stream_contract(events) + turn = ConversationTurn(payload, events) + self.turns.append(turn) + return turn + + def stream_expect_http_error( + self, + payload: dict[str, Any], + *, + expected_status: int, + ) -> dict[str, Any]: + response = httpx.post( + f"{self.server.base_url}/v1/messages", + headers=auth_headers(), + json=payload, + timeout=self.config.timeout_s, + ) + assert response.status_code == expected_status, response.text + return response.json() + + +class ProviderMatrixDriver: + """Resolve provider models and enforce matrix semantics for product smoke.""" + + ALL_PROVIDERS: tuple[str, ...] = SUPPORTED_PROVIDER_IDS + + def __init__(self, config: SmokeConfig) -> None: + self.config = config + + def configured_models(self) -> list[ProviderModel]: + return self.config.provider_models() + + def provider_smoke_models(self) -> list[ProviderModel]: + selected = self.config.provider_matrix + missing_selected = [ + provider + for provider in selected + if provider in self.ALL_PROVIDERS + and not self.config.has_provider_configuration(provider) + ] + if missing_selected: + fail_missing_env( + "selected providers are not configured: " + + ", ".join(sorted(missing_selected)) + ) + + models = self.config.provider_smoke_models() + if not models and os.getenv("FCC_ALLOW_NO_PROVIDER_SMOKE") != "1": + fail_missing_env( + "no configured provider smoke models; set FCC_ALLOW_NO_PROVIDER_SMOKE=1 " + "only for no-provider smoke collection" + ) + return models + + def first_model(self) -> ProviderModel: + models = self.provider_smoke_models() + if not models: + pytest.skip("missing_env: no configured provider model") + return models[0] + + +class ClientProtocolDriver: + """Build recorded/representative client protocol requests.""" + + @staticmethod + def vscode_headers() -> dict[str, str]: + headers = auth_headers() + headers.update( + { + "anthropic-beta": "messages-2023-12-15", + "user-agent": "Claude-Code-VSCode product smoke", + } + ) + return headers + + @staticmethod + def jetbrains_headers(config: SmokeConfig) -> dict[str, str]: + headers = auth_headers() + token = config.settings.anthropic_auth_token + if token: + headers.pop("x-api-key", None) + headers["authorization"] = f"Bearer {token}" + headers["user-agent"] = "JetBrains-ACP product smoke" + return headers + + @staticmethod + def adaptive_thinking_payload() -> dict[str, Any]: + return { + "model": "claude-opus-4-7", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "unsigned thought"}, + {"type": "redacted_thinking", "data": "opaque"}, + {"type": "text", "text": "Hello."}, + ], + }, + {"role": "user", "content": "Reply with exactly FCC_SMOKE_CLIENT"}, + ], + "thinking": {"type": "adaptive", "budget_tokens": 1024}, + } + + @staticmethod + def tool_result_payload() -> dict[str, Any]: + return { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "Use echo_smoke once."}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_client_smoke", + "name": "echo_smoke", + "input": {"value": "FCC_SMOKE_CLIENT"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_client_smoke", + "content": "FCC_SMOKE_CLIENT", + } + ], + }, + ], + "tools": [echo_tool_schema()], + "thinking": {"type": "adaptive"}, + } + + @staticmethod + def run_claude_prompt( + *, + claude_bin: str, + server: RunningServer, + config: SmokeConfig, + cwd: Path, + prompt: str, + model: str | None = None, + ) -> subprocess.CompletedProcess[str]: + env = build_claude_proxy_env( + proxy_root_url=server.base_url, + auth_token=config.settings.anthropic_auth_token, + base_env=os.environ, + ) + command = [ + claude_bin, + "--bare", + "--no-session-persistence", + "--tools", + "", + "--system-prompt", + "Reply with exactly the requested smoke token and no other text.", + ] + if model is not None: + command.extend(["--model", model]) + command.extend(["-p", prompt]) + return run_captured_text( + command, + cwd=cwd, + env=env, + timeout=config.timeout_s, + check=False, + ) + + +class FakePlatform: + """In-memory platform that exercises the real message handler.""" + + def __init__(self, name: str) -> None: + self.name = name + self.handler: Callable[[IncomingMessage], Awaitable[None]] | None = None + self.sent: list[dict[str, Any]] = [] + self.edits: list[dict[str, Any]] = [] + self.deletes: list[dict[str, Any]] = [] + self._counter = 0 + self._tasks: list[asyncio.Future[Any]] = [] + self._pending_voice: dict[ + tuple[MessageScope, str], VoiceCancellationResult + ] = {} + + async def start(self) -> None: + return None + + async def quiesce(self) -> None: + return None + + async def close(self) -> None: + for task in self._tasks: + if not task.done(): + task.cancel() + if self._tasks: + await asyncio.gather(*self._tasks, return_exceptions=True) + + @property + def is_connected(self) -> bool: + return True + + def on_message(self, handler: Callable[[IncomingMessage], Awaitable[None]]) -> None: + self.handler = handler + + def continue_message_sequence_after(self, previous: FakePlatform) -> None: + """Model platform-owned message IDs surviving an FCC restart.""" + self._counter = previous._counter + + async def emit(self, incoming: IncomingMessage) -> None: + assert self.handler is not None + await self.handler(incoming) + + def fire_and_forget(self, task: Awaitable[Any]) -> None: + self._tasks.append(asyncio.ensure_future(task)) + + async def send_message( + self, + chat_id: str, + text: str, + reply_to: str | None = None, + parse_mode: str | None = None, + message_thread_id: str | None = None, + ) -> str: + self._counter += 1 + message_id = f"{self.name}_msg_{self._counter}" + self.sent.append( + { + "chat_id": chat_id, + "message_id": message_id, + "text": text, + "reply_to": reply_to, + "parse_mode": parse_mode, + "message_thread_id": message_thread_id, + } + ) + return message_id + + async def edit_message( + self, + chat_id: str, + message_id: str, + text: str, + parse_mode: str | None = None, + ) -> None: + self.edits.append( + { + "chat_id": chat_id, + "message_id": message_id, + "text": text, + "parse_mode": parse_mode, + } + ) + + async def delete_message(self, chat_id: str, message_id: str) -> None: + self.deletes.append({"chat_id": chat_id, "message_id": message_id}) + + async def queue_send_message( + self, + chat_id: str, + text: str, + reply_to: str | None = None, + parse_mode: str | None = None, + fire_and_forget: bool = True, + message_thread_id: str | None = None, + ) -> str | None: + message_id = await self.send_message( + chat_id, + text, + reply_to=reply_to, + parse_mode=parse_mode, + message_thread_id=message_thread_id, + ) + return None if fire_and_forget else message_id + + async def queue_edit_message( + self, + chat_id: str, + message_id: str, + text: str, + parse_mode: str | None = None, + fire_and_forget: bool = True, + ) -> None: + await self.edit_message(chat_id, message_id, text, parse_mode=parse_mode) + + async def queue_delete_messages( + self, + chat_id: str, + message_ids: list[str], + fire_and_forget: bool = True, + ) -> None: + for message_id in message_ids: + await self.delete_message(chat_id, message_id) + + def seed_pending_voice( + self, chat_id: str, voice_message_id: str, status_message_id: str + ) -> None: + scope = MessageScope(platform=self.name, chat_id=chat_id) + result = VoiceCancellationResult( + scope=scope, + voice_message_id=voice_message_id, + status_message_id=status_message_id, + delete_message_ids=frozenset({voice_message_id, status_message_id}), + ) + self._pending_voice[(scope, voice_message_id)] = result + self._pending_voice[(scope, status_message_id)] = result + + async def cancel_pending_voice( + self, scope: MessageScope, reply_id: str + ) -> VoiceCancellationResult | None: + result = self._pending_voice.get((scope, reply_id)) + if result is None: + return None + self._pending_voice.pop((scope, result.voice_message_id), None) + if result.status_message_id is not None: + self._pending_voice.pop((scope, result.status_message_id), None) + delete_message_ids = {result.voice_message_id, result.status_message_id} + if reply_id == result.status_message_id: + delete_message_ids = {result.status_message_id} + return VoiceCancellationResult( + scope=result.scope, + voice_message_id=result.voice_message_id, + status_message_id=result.status_message_id, + delete_message_ids=frozenset( + message_id + for message_id in delete_message_ids + if message_id is not None + ), + ) + + async def cancel_all_pending_voices( + self, + ) -> tuple[VoiceCancellationResult, ...]: + results = tuple( + { + (result.scope, result.voice_message_id): result + for result in self._pending_voice.values() + }.values() + ) + self._pending_voice.clear() + return results + + async def cancel_pending_voices_in_scope( + self, + scope: MessageScope, + ) -> tuple[VoiceCancellationResult, ...]: + results = tuple( + { + result.voice_message_id: result + for (entry_scope, _reference_id), result in self._pending_voice.items() + if entry_scope == scope + }.values() + ) + for result in results: + self._pending_voice.pop((scope, result.voice_message_id), None) + if result.status_message_id is not None: + self._pending_voice.pop((scope, result.status_message_id), None) + return results + + @property + def pending_voice_count(self) -> int: + return len( + { + (result.scope, result.voice_message_id) + for result in self._pending_voice.values() + } + ) + + +class FakeCLISession: + def __init__(self, events: list[dict[str, Any]]) -> None: + self.events = events + self.calls: list[dict[str, Any]] = [] + self.is_busy = False + + async def start_task( + self, prompt: str, session_id: str | None = None, fork_session: bool = False + ) -> AsyncGenerator[dict[str, Any]]: + self.calls.append( + {"prompt": prompt, "session_id": session_id, "fork_session": fork_session} + ) + self.is_busy = True + try: + for event in self.events: + await asyncio.sleep(0) + yield event + finally: + self.is_busy = False + + +class FakeCLIManager: + def __init__(self, event_batches: list[list[dict[str, Any]]] | None = None) -> None: + self.event_batches = event_batches or [default_cli_events("fake_session_1")] + self.sessions: list[FakeCLISession] = [] + self.registered: list[tuple[str, str]] = [] + self.removed: list[str] = [] + self.stopped = False + + async def get_or_create_session( + self, session_id: str | None = None + ) -> tuple[FakeCLISession, str, bool]: + index = len(self.sessions) + events = self.event_batches[min(index, len(self.event_batches) - 1)] + session = FakeCLISession(events) + self.sessions.append(session) + return session, session_id or f"pending_{index}", session_id is None + + async def register_real_session_id( + self, temp_id: str, real_session_id: str + ) -> bool: + self.registered.append((temp_id, real_session_id)) + return True + + async def stop_all(self) -> None: + self.stopped = True + + async def remove_session(self, session_id: str) -> bool: + self.removed.append(session_id) + return True + + def get_stats(self) -> dict[str, int]: + return {"active_sessions": len(self.sessions), "pending_sessions": 0} + + +@dataclass(slots=True) +class FakePlatformDriver: + platform_name: str + tmp_path: Path + event_batches: list[list[dict[str, Any]]] | None = None + platform: FakePlatform = field(init=False) + cli_manager: FakeCLIManager = field(init=False) + session_store: SessionStore = field(init=False) + workflow: MessagingWorkflow = field(init=False) + + def __post_init__(self) -> None: + self.platform = FakePlatform(self.platform_name) + self.cli_manager = FakeCLIManager(self.event_batches) + self.session_store = SessionStore( + storage_path=str(self.tmp_path / f"{self.platform_name}-sessions.json") + ) + self.workflow = MessagingWorkflow( + self.platform, + self.cli_manager, + self.session_store, + platform_name=self.platform_name, + voice_cancellation=self.platform, + ) + self.platform.on_message(self.workflow.handle_message) + + async def send( + self, + text: str, + *, + chat_id: str = "chat_1", + message_id: str | None = None, + reply_to: str | None = None, + ) -> IncomingMessage: + incoming = await self.emit( + text, + chat_id=chat_id, + message_id=message_id, + reply_to=reply_to, + ) + await self.wait_for_idle() + return incoming + + async def emit( + self, + text: str, + *, + chat_id: str = "chat_1", + message_id: str | None = None, + reply_to: str | None = None, + ) -> IncomingMessage: + """Deliver a message without waiting for background claims to finish.""" + incoming = IncomingMessage( + text=text, + chat_id=chat_id, + user_id="user_1", + message_id=message_id or f"in_{uuid.uuid4().hex[:8]}", + platform=self.platform_name, + reply_to_message_id=reply_to, + ) + await self.platform.emit(incoming) + return incoming + + async def wait_for_idle(self, *, timeout_s: float = 5.0) -> None: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + pending = [task for task in self.platform._tasks if not task.done()] + if not pending and await self._all_tree_nodes_terminal(): + self.session_store.flush_pending_save() + return + await asyncio.sleep(0.02) + raise AssertionError("fake platform did not become idle") + + async def _all_tree_nodes_terminal(self) -> bool: + snapshot = await self.workflow.tree_queue.snapshot() + for tree in snapshot.trees.values(): + for node in tree.nodes.values(): + if node.get("state") in {"pending", "in_progress"}: + return False + return True + + +class VoiceFixtureDriver: + @staticmethod + def write_tone_wav(path: Path) -> None: + import math + + sample_rate = 16000 + duration_s = 0.25 + amplitude = 8000 + frames = bytearray() + for i in range(int(sample_rate * duration_s)): + sample = int(amplitude * math.sin(2 * math.pi * 440 * i / sample_rate)) + frames.extend(sample.to_bytes(2, byteorder="little", signed=True)) + with wave.open(str(path), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(sample_rate) + wav.writeframes(bytes(frames)) + + +def echo_tool_schema() -> dict[str, Any]: + return { + "name": "echo_smoke", + "description": "Echo a test value.", + "input_schema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + } + + +def assistant_content_from_events(events: list[SSEEvent]) -> list[dict[str, Any]]: + blocks: dict[int, dict[str, Any]] = {} + block_order: list[int] = [] + for event in events: + if event.event == "content_block_start": + index = event_index(event) + block = event.data.get("content_block", {}) + if isinstance(block, dict): + blocks[index] = dict(block) + block_order.append(index) + continue + if event.event == "content_block_delta": + index = event_index(event) + block = blocks.get(index) + delta = event.data.get("delta", {}) + if not isinstance(block, dict) or not isinstance(delta, dict): + continue + delta_type = delta.get("type") + if delta_type == "text_delta": + block["text"] = str(block.get("text", "")) + str(delta.get("text", "")) + elif delta_type == "thinking_delta": + block["thinking"] = str(block.get("thinking", "")) + str( + delta.get("thinking", "") + ) + elif delta_type == "input_json_delta": + block["_partial_json"] = str(block.get("_partial_json", "")) + str( + delta.get("partial_json", "") + ) + + content: list[dict[str, Any]] = [] + for index in block_order: + block = blocks[index] + if block.get("type") == "tool_use": + partial = str(block.pop("_partial_json", "")) + if partial: + try: + block["input"] = json.loads(partial) + except json.JSONDecodeError: + block["input"] = {} + content.append(block) + return content + + +def tool_use_blocks(events: list[SSEEvent]) -> list[dict[str, Any]]: + return [ + block + for block in assistant_content_from_events(events) + if block.get("type") == "tool_use" + ] + + +def default_cli_events(session_id: str) -> list[dict[str, Any]]: + return [ + {"type": "session_info", "session_id": session_id}, + { + "type": "assistant", + "message": { + "content": [ + {"type": "thinking", "thinking": "Inspect the request."}, + { + "type": "tool_use", + "id": "toolu_fake", + "name": "Read", + "input": {"file_path": "README.md"}, + }, + { + "type": "tool_result", + "tool_use_id": "toolu_fake", + "content": "Free Claude Code", + }, + {"type": "text", "text": "Fake platform answer."}, + ] + }, + }, + {"type": "exit", "code": 0, "stderr": None}, + ] + + +def assert_product_stream(events: list[SSEEvent]) -> None: + assert_anthropic_stream_contract(events) + assert text_content(events).strip() or has_tool_use(events), ( + "product stream emitted neither text nor tool_use" + ) diff --git a/smoke/lib/http.py b/smoke/lib/http.py new file mode 100644 index 0000000..e8811bb --- /dev/null +++ b/smoke/lib/http.py @@ -0,0 +1,69 @@ +"""HTTP helpers for live smoke requests.""" + +from typing import Any + +import httpx + +from free_claude_code.core.anthropic.stream_contracts import SSEEvent, parse_sse_lines + +from .config import SmokeConfig, auth_headers, redacted +from .server import RunningServer + + +def message_payload( + text: str, + *, + model: str = "claude-3-5-sonnet-20241022", + max_tokens: int = 128, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "model": model, + "max_tokens": max_tokens, + "messages": [{"role": "user", "content": text}], + } + if extra: + payload.update(extra) + return payload + + +def post_json( + server: RunningServer, + path: str, + payload: dict[str, Any], + config: SmokeConfig, + *, + headers: dict[str, str] | None = None, +) -> httpx.Response: + request_headers = headers or auth_headers() + response = httpx.post( + f"{server.base_url}{path}", + headers=request_headers, + json=payload, + timeout=config.timeout_s, + ) + return response + + +def collect_message_stream( + server: RunningServer, + payload: dict[str, Any], + config: SmokeConfig, + *, + headers: dict[str, str] | None = None, +) -> list[SSEEvent]: + request_headers = headers or auth_headers() + with httpx.stream( + "POST", + f"{server.base_url}/v1/messages", + headers=request_headers, + json=payload, + timeout=config.timeout_s, + ) as response: + if response.status_code != 200: + body = response.read().decode("utf-8", errors="replace") + raise AssertionError( + f"stream request failed: HTTP {response.status_code} " + f"{redacted(body[:1000])}" + ) + return parse_sse_lines(response.iter_lines()) diff --git a/smoke/lib/local_providers.py b/smoke/lib/local_providers.py new file mode 100644 index 0000000..39bf88e --- /dev/null +++ b/smoke/lib/local_providers.py @@ -0,0 +1,72 @@ +"""Helpers for local-provider smoke availability checks.""" + +from urllib.parse import urljoin + +import httpx +import pytest + +from free_claude_code.providers.openai_chat import openai_v1_base_url + +LOCAL_PROVIDER_PROBE_TIMEOUT_S = 1.5 +_ROOT_OR_V1_PROVIDERS = frozenset({"llamacpp", "ollama"}) + + +def first_local_provider_model_id( + provider: str, + base_url: str, + *, + timeout_s: float, +) -> str: + """Return the first local model id, or skip when the local server is absent.""" + base_url = base_url.strip() + if not base_url: + pytest.skip(f"missing_env: {provider} base URL is not configured") + + timeout = min(timeout_s, LOCAL_PROVIDER_PROBE_TIMEOUT_S) + if provider in _ROOT_OR_V1_PROVIDERS: + base_url = openai_v1_base_url(base_url) + return _first_openai_compatible_model_id( + provider, + base_url, + timeout_s=timeout, + ) + + +def _first_openai_compatible_model_id( + provider: str, + base_url: str, + *, + timeout_s: float, +) -> str: + models_url = urljoin(base_url.rstrip("/") + "/", "models") + response = _get_local_provider_response(provider, models_url, timeout_s=timeout_s) + assert response.status_code == 200, response.text + payload = response.json() + data = payload.get("data") if isinstance(payload, dict) else None + if isinstance(data, list): + for item in data: + if isinstance(item, dict) and isinstance(item.get("id"), str): + return item["id"] + pytest.skip(f"missing_env: {provider} local server has no loaded models") + pytest.fail("product_failure: local /models did not expose a model id") + + +def _get_local_provider_response( + provider: str, + url: str, + *, + timeout_s: float, +) -> httpx.Response: + try: + response = httpx.get(url, timeout=timeout_s) + except httpx.TimeoutException as exc: + pytest.skip(f"missing_env: {provider} local server is not reachable: {exc}") + except httpx.NetworkError as exc: + pytest.skip(f"missing_env: {provider} local server is not running: {exc}") + + if response.status_code in {404, 405, 502, 503}: + pytest.skip( + f"missing_env: {provider} local server is not available at {url}: " + f"HTTP {response.status_code}" + ) + return response diff --git a/smoke/lib/report.py b/smoke/lib/report.py new file mode 100644 index 0000000..25860b7 --- /dev/null +++ b/smoke/lib/report.py @@ -0,0 +1,105 @@ +"""Small JSON report writer for smoke runs.""" + +import json +import time +from dataclasses import asdict, dataclass + +from .config import SmokeConfig, redacted + + +@dataclass(slots=True) +class SmokeOutcome: + nodeid: str + outcome: str + classification: str + duration_s: float + markers: list[str] + detail: str + + +class SmokeReport: + def __init__(self, config: SmokeConfig) -> None: + self.config = config + self.started_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + self.outcomes: list[SmokeOutcome] = [] + + def add( + self, + *, + nodeid: str, + outcome: str, + duration_s: float, + markers: list[str], + detail: str = "", + ) -> None: + self.outcomes.append( + SmokeOutcome( + nodeid=nodeid, + outcome=outcome, + classification=classify_outcome( + nodeid=nodeid, outcome=outcome, detail=detail + ), + duration_s=duration_s, + markers=markers, + detail=redacted(detail), + ) + ) + + def write(self) -> None: + self.config.results_dir.mkdir(parents=True, exist_ok=True) + path = ( + self.config.results_dir + / f"report-{self.config.worker_id}-{int(time.time())}.json" + ) + payload = { + "started_at": self.started_at, + "worker_id": self.config.worker_id, + "targets": sorted(self.config.targets), + "outcomes": [asdict(outcome) for outcome in self.outcomes], + } + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + +def classify_outcome(*, nodeid: str, outcome: str, detail: str) -> str: + """Classify smoke outcomes for triage reports.""" + if outcome == "passed": + return "passed" + + text = f"{nodeid}\n{detail}".lower() + if outcome == "skipped": + if "smoke target disabled" in text: + return "target_disabled" + if "missing_env" in text: + return "missing_env" + if any( + marker in text + for marker in ( + "upstream_unavailable", + "connection refused", + "connecterror", + "readtimeout", + "timed out", + "not reachable", + ) + ): + return "upstream_unavailable" + return "missing_env" + + if "harness_bug" in text: + return "harness_bug" + if "missing_env" in text: + return "missing_env" + if any( + marker in text + for marker in ( + "upstream_unavailable", + "connection refused", + "connecterror", + "readtimeout", + "timed out", + "not reachable", + "upstream provider", + ) + ): + return "upstream_unavailable" + return "product_failure" diff --git a/smoke/lib/report_summary.py b/smoke/lib/report_summary.py new file mode 100644 index 0000000..86eed35 --- /dev/null +++ b/smoke/lib/report_summary.py @@ -0,0 +1,51 @@ +"""Summarize smoke JSON reports for local and workflow triage.""" + +import json +from collections import Counter +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class SmokeSummary: + reports: int + outcomes: int + classifications: dict[str, int] + + @property + def has_regression(self) -> bool: + return bool( + self.classifications.get("product_failure", 0) + or self.classifications.get("harness_bug", 0) + ) + + +def summarize_reports(results_dir: Path) -> SmokeSummary: + """Read all report JSON files and count outcome classifications.""" + counts: Counter[str] = Counter() + reports = 0 + outcomes = 0 + for path in sorted(results_dir.glob("report-*.json")): + reports += 1 + payload = json.loads(path.read_text(encoding="utf-8")) + for outcome in payload.get("outcomes", []): + if not isinstance(outcome, dict): + continue + outcomes += 1 + counts[str(outcome.get("classification") or "unknown")] += 1 + return SmokeSummary( + reports=reports, + outcomes=outcomes, + classifications=dict(sorted(counts.items())), + ) + + +def format_summary(summary: SmokeSummary) -> str: + """Return a compact human-readable summary.""" + parts = [ + f"reports={summary.reports}", + f"outcomes={summary.outcomes}", + ] + parts.extend(f"{name}={count}" for name, count in summary.classifications.items()) + status = "regression" if summary.has_regression else "ok" + return f"smoke_summary status={status} " + " ".join(parts) diff --git a/smoke/lib/server.py b/smoke/lib/server.py new file mode 100644 index 0000000..553e8c6 --- /dev/null +++ b/smoke/lib/server.py @@ -0,0 +1,118 @@ +"""Subprocess lifecycle helpers for local smoke servers.""" + +import os +import socket +import subprocess +import time +from collections.abc import Iterable, Iterator +from contextlib import contextmanager, suppress +from dataclasses import dataclass +from pathlib import Path + +import httpx + +from .child_process import cmd_free_claude_code_serve +from .config import SmokeConfig, redacted + + +@dataclass(slots=True) +class RunningServer: + base_url: str + port: int + log_path: Path + process: subprocess.Popen[bytes] + + +def find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +@contextmanager +def start_server( + config: SmokeConfig, + *, + env_overrides: dict[str, str] | None = None, + env_unset: Iterable[str] = (), + command: list[str] | None = None, + name: str = "server", +) -> Iterator[RunningServer]: + port = find_free_port() + config.results_dir.mkdir(parents=True, exist_ok=True) + log_path = config.results_dir / f"{name}-{config.worker_id}-{port}.log" + + env = os.environ.copy() + for key in env_unset: + env.pop(key, None) + env.update( + { + "HOST": "127.0.0.1", + "PORT": str(port), + "LOG_FILE": str(log_path), + "FCC_OPEN_BROWSER": "0", + "MESSAGING_PLATFORM": "none", + "PYTHONUNBUFFERED": "1", + } + ) + if env_overrides: + env.update(env_overrides) + + cmd = command or cmd_free_claude_code_serve() + + with log_path.open("ab") as log_file: + process = subprocess.Popen( + cmd, + cwd=config.root, + env=env, + stdout=log_file, + stderr=subprocess.STDOUT, + ) + running = RunningServer( + base_url=f"http://127.0.0.1:{port}", + port=port, + log_path=log_path, + process=process, + ) + try: + _wait_for_health(running, timeout_s=config.timeout_s) + yield running + finally: + _stop_process(process) + + +def _wait_for_health(server: RunningServer, *, timeout_s: float) -> None: + deadline = time.monotonic() + timeout_s + last_error = "" + while time.monotonic() < deadline: + if server.process.poll() is not None: + break + try: + response = httpx.get(f"{server.base_url}/health", timeout=2.0) + if response.status_code == 200: + return + last_error = f"HTTP {response.status_code}: {response.text[:200]}" + except Exception as exc: + last_error = f"{type(exc).__name__}: {exc}" + time.sleep(0.25) + + log_excerpt = "" + with suppress(OSError): + log_excerpt = server.log_path.read_text(encoding="utf-8", errors="replace")[ + -2000: + ] + raise AssertionError( + "Smoke server did not become healthy. " + f"last_error={last_error!r} log={redacted(log_excerpt)!r}" + ) + + +def _stop_process(process: subprocess.Popen[bytes]) -> None: + if process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=8) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) diff --git a/smoke/lib/skips.py b/smoke/lib/skips.py new file mode 100644 index 0000000..e8407fa --- /dev/null +++ b/smoke/lib/skips.py @@ -0,0 +1,67 @@ +"""Skip helpers for expected live-smoke environment gaps.""" + +import httpx +import pytest + +from free_claude_code.core.anthropic.stream_contracts import SSEEvent, text_content + +UPSTREAM_UNAVAILABLE_MARKERS = ( + "connection refused", + "connecterror", + "connect timeout", + "readtimeout", + "server disconnected", + "service unavailable", + "temporary failure", + "timed out", + "upstream provider", +) + + +def is_upstream_unavailable_text(text: str) -> bool: + normalized = text.lower() + return any(marker in normalized for marker in UPSTREAM_UNAVAILABLE_MARKERS) + + +def skip_upstream_unavailable(reason: str) -> None: + pytest.skip(f"upstream_unavailable: {reason}") + + +def fail_missing_env(reason: str) -> None: + pytest.fail(f"missing_env: {reason}") + + +def skip_if_upstream_unavailable_exception(exc: Exception) -> None: + if isinstance( + exc, + ( + httpx.ConnectError, + httpx.ConnectTimeout, + httpx.ReadTimeout, + httpx.RemoteProtocolError, + httpx.ProxyError, + ), + ): + skip_upstream_unavailable(f"{type(exc).__name__}: {exc}") + if is_upstream_unavailable_text(f"{type(exc).__name__}: {exc}"): + skip_upstream_unavailable(f"{type(exc).__name__}: {exc}") + + +def skip_if_upstream_unavailable_events(events: list[SSEEvent]) -> None: + text = text_content(events) + if is_upstream_unavailable_text(text): + skip_upstream_unavailable(text[:500]) + + for event in events: + if getattr(event, "event", None) != "error": + continue + data = getattr(event, "data", {}) + message = "" + if isinstance(data, dict): + error = data.get("error") + if isinstance(error, dict): + message = str(error.get("message", "")) + else: + message = str(data) + if is_upstream_unavailable_text(message): + skip_upstream_unavailable(message[:500]) diff --git a/smoke/prereq/__init__.py b/smoke/prereq/__init__.py new file mode 100644 index 0000000..54472fd --- /dev/null +++ b/smoke/prereq/__init__.py @@ -0,0 +1 @@ +"""Live prerequisite checks for product smoke.""" diff --git a/smoke/prereq/test_api_prereq_live.py b/smoke/prereq/test_api_prereq_live.py new file mode 100644 index 0000000..da42b00 --- /dev/null +++ b/smoke/prereq/test_api_prereq_live.py @@ -0,0 +1,194 @@ +from typing import Any + +import httpx +import pytest + +from smoke.lib.config import SmokeConfig +from smoke.lib.http import post_json +from smoke.lib.server import RunningServer + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("api")] + + +def test_probe_and_models_routes( + smoke_server: RunningServer, smoke_headers: dict[str, str] +) -> None: + with httpx.Client(base_url=smoke_server.base_url, headers=smoke_headers) as client: + assert client.get("/health").json()["status"] == "healthy" + + root = client.get("/") + assert root.status_code == 200 + assert root.json()["status"] == "ok" + + models = client.get("/v1/models") + assert models.status_code == 200 + assert models.json()["data"] + + for path in ( + "/", + "/health", + "/v1/messages", + "/v1/responses", + "/v1/messages/count_tokens", + ): + head = client.head(path) + assert head.status_code == 204, (path, head.status_code, head.text) + options = client.options(path) + assert options.status_code == 204, (path, options.status_code, options.text) + + +def test_count_tokens_accepts_thinking_tools_and_results( + smoke_server: RunningServer, + smoke_config: SmokeConfig, + smoke_headers: dict[str, str], +) -> None: + payload: dict[str, Any] = { + "model": "claude-3-5-sonnet-20241022", + "messages": [ + {"role": "user", "content": "Use the tool."}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Need to inspect the file."}, + { + "type": "tool_use", + "id": "toolu_smoke", + "name": "Read", + "input": {"file_path": "README.md"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_smoke", + "content": "Free Claude Code", + } + ], + }, + ], + "tools": [ + { + "name": "Read", + "description": "Read a file", + "input_schema": { + "type": "object", + "properties": {"file_path": {"type": "string"}}, + "required": ["file_path"], + }, + } + ], + } + response = post_json( + smoke_server, + "/v1/messages/count_tokens", + payload, + smoke_config, + headers=smoke_headers, + ) + assert response.status_code == 200, response.text + assert response.json()["input_tokens"] > 0 + + +def test_optimization_fast_paths_do_not_need_provider( + smoke_server: RunningServer, + smoke_config: SmokeConfig, + smoke_headers: dict[str, str], +) -> None: + cases: tuple[tuple[str, dict[str, Any], str], ...] = ( + ( + "quota", + { + "model": "claude-3-5-sonnet-20241022", + "max_tokens": 1, + "messages": [{"role": "user", "content": "quota"}], + }, + "Quota check passed.", + ), + ( + "title", + { + "model": "claude-3-5-sonnet-20241022", + "system": ( + "Generate a concise, sentence-case title (3-7 words). " + 'Return JSON with a single "title" field.' + ), + "messages": [{"role": "user", "content": "hello"}], + }, + "Conversation", + ), + ( + "prefix", + { + "model": "claude-3-5-sonnet-20241022", + "messages": [ + { + "role": "user", + "content": "extract command\nCommand: git status --short", + } + ], + }, + "git", + ), + ( + "suggestion", + { + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "[SUGGESTION MODE: next]"}], + }, + "", + ), + ( + "filepath", + { + "model": "claude-3-5-sonnet-20241022", + "system": "Extract any file paths that this command output contains.", + "messages": [ + { + "role": "user", + "content": "Command: cat smoke/test_api_live.py\nOutput: file contents\n", + } + ], + }, + "smoke/test_api_live.py", + ), + ) + for name, payload, expected_text in cases: + response = post_json( + smoke_server, "/v1/messages", payload, smoke_config, headers=smoke_headers + ) + assert response.status_code == 200, (name, response.text) + text = response.json()["content"][0]["text"] + assert expected_text in text + + +def test_invalid_messages_returns_anthropic_error( + smoke_server: RunningServer, + smoke_config: SmokeConfig, + smoke_headers: dict[str, str], +) -> None: + response = post_json( + smoke_server, + "/v1/messages", + {"model": "claude-3-5-sonnet-20241022", "messages": []}, + smoke_config, + headers=smoke_headers, + ) + assert response.status_code == 400 + payload = response.json() + assert payload["type"] == "error" + assert payload["error"]["type"] == "invalid_request_error" + + +def test_stop_endpoint_reports_no_messaging( + smoke_server: RunningServer, smoke_headers: dict[str, str] +) -> None: + response = httpx.post( + f"{smoke_server.base_url}/stop", + headers=smoke_headers, + timeout=5, + ) + assert response.status_code == 503 + assert response.json()["detail"] == "Messaging system not initialized" diff --git a/smoke/prereq/test_auth_prereq_live.py b/smoke/prereq/test_auth_prereq_live.py new file mode 100644 index 0000000..ba35320 --- /dev/null +++ b/smoke/prereq/test_auth_prereq_live.py @@ -0,0 +1,50 @@ +from pathlib import Path + +import httpx +import pytest + +from smoke.lib.config import SmokeConfig +from smoke.lib.server import start_server + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("auth")] + + +def test_auth_token_is_enforced_for_all_supported_header_shapes( + smoke_config: SmokeConfig, tmp_path: Path +) -> None: + token = "fcc-smoke-token" + env_file = tmp_path / "auth.env" + env_file.write_text(f'ANTHROPIC_AUTH_TOKEN="{token}"\n', encoding="utf-8") + + with start_server( + smoke_config, + env_overrides={ + "ANTHROPIC_AUTH_TOKEN": token, + "FCC_ENV_FILE": str(env_file), + "MESSAGING_PLATFORM": "none", + }, + name="auth", + ) as server: + assert httpx.get(f"{server.base_url}/").status_code == 401 + assert ( + httpx.get(f"{server.base_url}/", headers={"x-api-key": "wrong"}).status_code + == 401 + ) + assert ( + httpx.get(f"{server.base_url}/", headers={"x-api-key": token}).status_code + == 200 + ) + assert ( + httpx.get( + f"{server.base_url}/", + headers={"authorization": f"Bearer {token}"}, + ).status_code + == 200 + ) + assert ( + httpx.get( + f"{server.base_url}/", + headers={"anthropic-auth-token": token}, + ).status_code + == 200 + ) diff --git a/smoke/prereq/test_cli_prereq_live.py b/smoke/prereq/test_cli_prereq_live.py new file mode 100644 index 0000000..d541168 --- /dev/null +++ b/smoke/prereq/test_cli_prereq_live.py @@ -0,0 +1,82 @@ +import os +import shutil +from pathlib import Path + +import pytest + +from free_claude_code.cli.claude_env import build_claude_proxy_env +from smoke.lib.child_process import ( + cmd_fcc_init, + cmd_free_claude_code_serve, + run_captured_text, +) +from smoke.lib.config import SmokeConfig +from smoke.lib.server import start_server +from smoke.lib.skips import skip_upstream_unavailable + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("cli")] + + +def test_fcc_init_scaffolds_user_config( + smoke_config: SmokeConfig, tmp_path: Path +) -> None: + env = os.environ.copy() + env["HOME"] = str(tmp_path) + env["USERPROFILE"] = str(tmp_path) + result = run_captured_text( + cmd_fcc_init(), + cwd=smoke_config.root, + env=env, + timeout=smoke_config.timeout_s, + check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout + assert (tmp_path / ".fcc" / ".env").is_file() + + +def test_free_claude_code_entrypoint_starts_server(smoke_config: SmokeConfig) -> None: + with start_server( + smoke_config, + command=cmd_free_claude_code_serve(), + env_overrides={"MESSAGING_PLATFORM": "none"}, + name="entrypoint", + ) as server: + assert server.process.poll() is None + + +def test_claude_cli_prompt_when_available( + smoke_config: SmokeConfig, tmp_path: Path +) -> None: + claude_bin = shutil.which(smoke_config.claude_bin) + if not claude_bin: + pytest.skip(f"Claude CLI not found: {smoke_config.claude_bin}") + models = smoke_config.provider_models() + if not models: + pytest.skip("no configured provider model available for Claude CLI smoke") + + with start_server( + smoke_config, + env_overrides={"MODEL": models[0].full_model, "MESSAGING_PLATFORM": "none"}, + name="claude-cli", + ) as server: + env = build_claude_proxy_env( + proxy_root_url=server.base_url, + auth_token=smoke_config.settings.anthropic_auth_token, + base_env=os.environ, + ) + result = run_captured_text( + [claude_bin, "-p", "Reply with exactly FCC_SMOKE_PONG"], + cwd=tmp_path, + env=env, + timeout=smoke_config.timeout_s, + check=False, + ) + server_log = server.log_path.read_text(encoding="utf-8", errors="replace") + assert result.returncode == 0, result.stderr or result.stdout + assert "POST /v1/messages" in server_log, ( + "Claude CLI did not call the local Anthropic-compatible endpoint" + ) + if "FCC_SMOKE_PONG" not in result.stdout: + skip_upstream_unavailable( + "Claude CLI reached the local proxy but returned no smoke token" + ) diff --git a/smoke/prereq/test_client_shapes_prereq_live.py b/smoke/prereq/test_client_shapes_prereq_live.py new file mode 100644 index 0000000..3f173cb --- /dev/null +++ b/smoke/prereq/test_client_shapes_prereq_live.py @@ -0,0 +1,47 @@ +import pytest + +from smoke.lib.config import SmokeConfig, auth_headers +from smoke.lib.http import message_payload, post_json +from smoke.lib.server import RunningServer + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("clients")] + + +def test_vscode_and_jetbrains_shaped_requests( + smoke_server: RunningServer, + smoke_config: SmokeConfig, +) -> None: + payload = message_payload("quota", max_tokens=1) + + vscode_headers = auth_headers() + vscode_headers.update( + { + "anthropic-beta": "messages-2023-12-15", + "user-agent": "Claude-Code-VSCode smoke", + } + ) + vscode = post_json( + smoke_server, + "/v1/messages?beta=true", + payload, + smoke_config, + headers=vscode_headers, + ) + assert vscode.status_code == 200, vscode.text + assert vscode.json()["content"][0]["text"] == "Quota check passed." + + jetbrains_headers = auth_headers() + token = smoke_config.settings.anthropic_auth_token + if token: + jetbrains_headers.pop("x-api-key", None) + jetbrains_headers["authorization"] = f"Bearer {token}" + jetbrains_headers["user-agent"] = "JetBrains-ACP smoke" + jetbrains = post_json( + smoke_server, + "/v1/messages", + payload, + smoke_config, + headers=jetbrains_headers, + ) + assert jetbrains.status_code == 200, jetbrains.text + assert jetbrains.json()["content"][0]["text"] == "Quota check passed." diff --git a/smoke/prereq/test_local_provider_endpoints_prereq_live.py b/smoke/prereq/test_local_provider_endpoints_prereq_live.py new file mode 100644 index 0000000..cbffa9c --- /dev/null +++ b/smoke/prereq/test_local_provider_endpoints_prereq_live.py @@ -0,0 +1,34 @@ +import pytest + +from smoke.lib.config import SmokeConfig +from smoke.lib.local_providers import first_local_provider_model_id + + +@pytest.mark.live +@pytest.mark.smoke_target("lmstudio") +def test_lmstudio_models_endpoint_when_available(smoke_config: SmokeConfig) -> None: + first_local_provider_model_id( + "lmstudio", + smoke_config.settings.lm_studio_base_url, + timeout_s=smoke_config.timeout_s, + ) + + +@pytest.mark.live +@pytest.mark.smoke_target("llamacpp") +def test_llamacpp_models_endpoint_when_available(smoke_config: SmokeConfig) -> None: + first_local_provider_model_id( + "llamacpp", + smoke_config.settings.llamacpp_base_url, + timeout_s=smoke_config.timeout_s, + ) + + +@pytest.mark.live +@pytest.mark.smoke_target("ollama") +def test_ollama_models_endpoint_when_available(smoke_config: SmokeConfig) -> None: + first_local_provider_model_id( + "ollama", + smoke_config.settings.ollama_base_url, + timeout_s=smoke_config.timeout_s, + ) diff --git a/smoke/prereq/test_messaging_prereq_live.py b/smoke/prereq/test_messaging_prereq_live.py new file mode 100644 index 0000000..a53a3a4 --- /dev/null +++ b/smoke/prereq/test_messaging_prereq_live.py @@ -0,0 +1,111 @@ +import os +import time + +import httpx +import pytest + +from smoke.lib.config import SmokeConfig + + +@pytest.mark.live +@pytest.mark.smoke_target("telegram") +def test_telegram_bot_api_permissions(smoke_config: SmokeConfig) -> None: + token = smoke_config.settings.telegram_bot_token + if not token: + pytest.skip("TELEGRAM_BOT_TOKEN is not configured") + + base_url = f"https://api.telegram.org/bot{token}" + get_me = httpx.get(f"{base_url}/getMe", timeout=smoke_config.timeout_s) + assert get_me.status_code == 200, get_me.text + assert get_me.json()["ok"] is True + + chat_id = os.getenv("FCC_SMOKE_TELEGRAM_CHAT_ID") or ( + smoke_config.settings.allowed_telegram_user_id or "" + ) + if not chat_id: + pytest.skip("FCC_SMOKE_TELEGRAM_CHAT_ID or ALLOWED_TELEGRAM_USER_ID required") + + marker = f"FCC smoke {int(time.time())}" + sent = httpx.post( + f"{base_url}/sendMessage", + json={"chat_id": chat_id, "text": marker}, + timeout=smoke_config.timeout_s, + ) + assert sent.status_code == 200, sent.text + message_id = sent.json()["result"]["message_id"] + + edited = httpx.post( + f"{base_url}/editMessageText", + json={"chat_id": chat_id, "message_id": message_id, "text": marker + " edit"}, + timeout=smoke_config.timeout_s, + ) + assert edited.status_code == 200, edited.text + + deleted = httpx.post( + f"{base_url}/deleteMessage", + json={"chat_id": chat_id, "message_id": message_id}, + timeout=smoke_config.timeout_s, + ) + assert deleted.status_code == 200, deleted.text + + +@pytest.mark.live +@pytest.mark.smoke_target("discord") +def test_discord_bot_api_permissions(smoke_config: SmokeConfig) -> None: + token = smoke_config.settings.discord_bot_token + channel_id = os.getenv("FCC_SMOKE_DISCORD_CHANNEL_ID") + if not channel_id and smoke_config.settings.allowed_discord_channels: + channel_id = smoke_config.settings.allowed_discord_channels.split(",", 1)[0] + if not token: + pytest.skip("DISCORD_BOT_TOKEN is not configured") + if not channel_id: + pytest.skip("FCC_SMOKE_DISCORD_CHANNEL_ID or ALLOWED_DISCORD_CHANNELS required") + + headers = {"authorization": f"Bot {token}"} + base_url = "https://discord.com/api/v10" + + channel = httpx.get( + f"{base_url}/channels/{channel_id}", + headers=headers, + timeout=smoke_config.timeout_s, + ) + assert channel.status_code == 200, channel.text + + marker = f"FCC smoke {int(time.time())}" + sent = httpx.post( + f"{base_url}/channels/{channel_id}/messages", + headers=headers, + json={"content": marker}, + timeout=smoke_config.timeout_s, + ) + assert sent.status_code == 200, sent.text + message_id = sent.json()["id"] + + edited = httpx.patch( + f"{base_url}/channels/{channel_id}/messages/{message_id}", + headers=headers, + json={"content": marker + " edit"}, + timeout=smoke_config.timeout_s, + ) + assert edited.status_code == 200, edited.text + + deleted = httpx.delete( + f"{base_url}/channels/{channel_id}/messages/{message_id}", + headers=headers, + timeout=smoke_config.timeout_s, + ) + assert deleted.status_code in {200, 204}, deleted.text + + +@pytest.mark.live +@pytest.mark.smoke_target("telegram") +@pytest.mark.smoke_target("discord") +def test_interactive_inbound_messaging_requires_explicit_mode( + smoke_config: SmokeConfig, +) -> None: + if not smoke_config.interactive: + pytest.skip("set FCC_SMOKE_INTERACTIVE=1 for manual inbound messaging checks") + pytest.skip( + "manual inbound check: start the server, send a nonce from the real client, " + "and verify threaded progress plus /stop, /clear, and /stats" + ) diff --git a/smoke/prereq/test_provider_prereq_live.py b/smoke/prereq/test_provider_prereq_live.py new file mode 100644 index 0000000..b483763 --- /dev/null +++ b/smoke/prereq/test_provider_prereq_live.py @@ -0,0 +1,104 @@ +import time + +import httpx +import pytest + +from free_claude_code.core.anthropic.stream_contracts import ( + assert_anthropic_stream_contract, + text_content, + thinking_content, +) +from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers +from smoke.lib.e2e import ProviderMatrixDriver +from smoke.lib.http import collect_message_stream, message_payload +from smoke.lib.server import start_server +from smoke.lib.skips import ( + skip_if_upstream_unavailable_events, + skip_if_upstream_unavailable_exception, +) + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("providers")] + + +def test_model_mapping_configuration_is_consistent(smoke_config: SmokeConfig) -> None: + models = smoke_config.provider_models() + if not models: + pytest.skip("no configured provider models with usable credentials/base URLs") + for provider_model in models: + assert "/" in provider_model.full_model + assert provider_model.model_name + + +def test_mixed_provider_model_mapping_when_configured( + smoke_config: SmokeConfig, +) -> None: + models = smoke_config.provider_models() + providers = {provider_model.provider for provider_model in models} + if len(providers) < 2: + pytest.skip("configure MODEL_* with at least two provider prefixes") + + sources = {provider_model.source for provider_model in models} + assert sources <= {"MODEL", "MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"} + assert len(providers) >= 2 + + +def test_configured_provider_models_stream_successfully( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + try: + with start_server( + smoke_config, + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + name=f"provider-{provider_model.provider}", + ) as server: + events = collect_message_stream( + server, + message_payload(smoke_config.prompt, model="fcc-smoke-default"), + smoke_config, + ) + skip_if_upstream_unavailable_events(events) + assert_anthropic_stream_contract(events) + has_text = bool(text_content(events).strip()) + has_thinking = bool(thinking_content(events).strip()) + assert has_text or has_thinking, ( + "provider returned no visible text or thinking content" + ) + except Exception as exc: + skip_if_upstream_unavailable_exception(exc) + raise AssertionError( + f"{provider_model.source}={provider_model.full_model}: " + f"{type(exc).__name__}: {exc}" + ) from exc + + +@pytest.mark.smoke_target("rate_limit") +def test_client_disconnect_mid_stream_does_not_crash_server( + smoke_config: SmokeConfig, +) -> None: + provider_model = ProviderMatrixDriver(smoke_config).first_model() + + with start_server( + smoke_config, + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + name="disconnect", + ) as server: + with httpx.stream( + "POST", + f"{server.base_url}/v1/messages", + headers=auth_headers(), + json=message_payload(smoke_config.prompt, model="fcc-smoke-default"), + timeout=smoke_config.timeout_s, + ) as response: + assert response.status_code == 200, response.read() + for _line in response.iter_lines(): + break + + time.sleep(0.5) + health = httpx.get(f"{server.base_url}/health", timeout=5) + assert health.status_code == 200 diff --git a/smoke/prereq/test_tools_prereq_live.py b/smoke/prereq/test_tools_prereq_live.py new file mode 100644 index 0000000..79e6f5b --- /dev/null +++ b/smoke/prereq/test_tools_prereq_live.py @@ -0,0 +1,61 @@ +import pytest + +from free_claude_code.core.anthropic.stream_contracts import ( + assert_anthropic_stream_contract, + has_tool_use, +) +from smoke.lib.config import SmokeConfig +from smoke.lib.http import collect_message_stream, message_payload +from smoke.lib.server import start_server +from smoke.lib.skips import ( + skip_if_upstream_unavailable_events, + skip_if_upstream_unavailable_exception, +) + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("tools")] + + +def test_live_tool_use_when_configured_model_supports_tools( + smoke_config: SmokeConfig, +) -> None: + models = smoke_config.provider_models() + if not models: + pytest.skip("no configured provider model available for tool-use smoke") + provider_model = models[0] + + payload = message_payload( + "Use the echo_smoke tool once with value FCC_SMOKE_TOOL.", + model="fcc-smoke-default", + max_tokens=256, + extra={ + "tools": [ + { + "name": "echo_smoke", + "description": "Echo a test value.", + "input_schema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + } + ], + "tool_choice": {"type": "tool", "name": "echo_smoke"}, + }, + ) + + with start_server( + smoke_config, + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + name="tools", + ) as server: + try: + events = collect_message_stream(server, payload, smoke_config) + except Exception as exc: + skip_if_upstream_unavailable_exception(exc) + raise + skip_if_upstream_unavailable_events(events) + assert_anthropic_stream_contract(events) + assert has_tool_use(events), "model did not emit a tool_use block" diff --git a/smoke/prereq/test_voice_prereq_live.py b/smoke/prereq/test_voice_prereq_live.py new file mode 100644 index 0000000..78091eb --- /dev/null +++ b/smoke/prereq/test_voice_prereq_live.py @@ -0,0 +1,62 @@ +import math +import os +import wave +from pathlib import Path + +import pytest + +from free_claude_code.messaging.transcription import TranscriptionService +from free_claude_code.messaging.voice import Transcriber +from free_claude_code.providers.nvidia_nim.voice import NvidiaNimTranscriber +from smoke.lib.config import SmokeConfig + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("voice")] + + +@pytest.mark.asyncio +async def test_voice_transcription_backend_when_explicitly_enabled( + smoke_config: SmokeConfig, tmp_path: Path +) -> None: + if not smoke_config.settings.voice_note_enabled: + pytest.skip("VOICE_NOTE_ENABLED is false") + if os.getenv("FCC_SMOKE_RUN_VOICE") != "1": + pytest.skip("set FCC_SMOKE_RUN_VOICE=1 to run transcription smoke") + + wav_path = tmp_path / "smoke-tone.wav" + _write_tone_wav(wav_path) + transcriber: Transcriber + if smoke_config.settings.whisper_device == "nvidia_nim": + transcriber = NvidiaNimTranscriber( + model=smoke_config.settings.whisper_model, + api_key=smoke_config.settings.nvidia_nim_api_key, + ) + else: + transcriber = TranscriptionService( + model=smoke_config.settings.whisper_model, + device=smoke_config.settings.whisper_device, + huggingface_api_key=smoke_config.settings.huggingface_api_key, + ) + try: + text = await transcriber.transcribe(wav_path) + except ImportError as exc: + pytest.skip(str(exc)) + finally: + await transcriber.close() + assert isinstance(text, str) + assert text.strip() + + +def _write_tone_wav(path: Path) -> None: + sample_rate = 16000 + duration_s = 0.25 + amplitude = 8000 + frames = bytearray() + for i in range(int(sample_rate * duration_s)): + sample = int(amplitude * math.sin(2 * math.pi * 440 * i / sample_rate)) + frames.extend(sample.to_bytes(2, byteorder="little", signed=True)) + + with wave.open(str(path), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(sample_rate) + wav.writeframes(bytes(frames)) diff --git a/smoke/product/__init__.py b/smoke/product/__init__.py new file mode 100644 index 0000000..662aac9 --- /dev/null +++ b/smoke/product/__init__.py @@ -0,0 +1 @@ +"""Product-level end-to-end smoke scenarios.""" diff --git a/smoke/product/test_api_product_live.py b/smoke/product/test_api_product_live.py new file mode 100644 index 0000000..54465cb --- /dev/null +++ b/smoke/product/test_api_product_live.py @@ -0,0 +1,200 @@ +from typing import Any + +import httpx +import pytest + +from smoke.lib.config import SmokeConfig +from smoke.lib.e2e import ( + ConversationDriver, + ProviderMatrixDriver, + SmokeServerDriver, + assert_product_stream, + echo_tool_schema, +) + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("api")] + + +def test_api_basic_conversation_e2e(smoke_config: SmokeConfig) -> None: + provider_model = ProviderMatrixDriver(smoke_config).first_model() + with SmokeServerDriver( + smoke_config, + name="product-api-basic", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + turn = ConversationDriver(server, smoke_config).ask( + "Reply with one short sentence." + ) + + assert_product_stream(turn.events) + assert turn.text.strip() + + +def test_api_count_tokens_full_payload_e2e( + smoke_server, + smoke_config: SmokeConfig, + smoke_headers: dict[str, str], +) -> None: + payload: dict[str, Any] = { + "model": "claude-3-5-sonnet-20241022", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Use the image and tool."}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "iVBORw0KGgo=", + }, + }, + ], + }, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Need the tool."}, + {"type": "redacted_thinking", "data": "opaque"}, + { + "type": "tool_use", + "id": "toolu_smoke", + "name": "echo_smoke", + "input": {"value": "FCC"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_smoke", + "content": "FCC", + } + ], + }, + ], + "tools": [echo_tool_schema()], + "thinking": {"type": "adaptive", "budget_tokens": 1024}, + } + response = httpx.post( + f"{smoke_server.base_url}/v1/messages/count_tokens", + headers=smoke_headers, + json=payload, + timeout=smoke_config.timeout_s, + ) + assert response.status_code == 200, response.text + assert response.json()["input_tokens"] > 0 + + +def test_api_request_optimizations_e2e( + smoke_server, + smoke_config: SmokeConfig, + smoke_headers: dict[str, str], +) -> None: + cases: tuple[tuple[str, dict[str, Any], str], ...] = ( + ( + "quota", + { + "model": "claude-3-5-sonnet-20241022", + "max_tokens": 1, + "messages": [{"role": "user", "content": "quota"}], + }, + "Quota check passed.", + ), + ( + "title", + { + "model": "claude-3-5-sonnet-20241022", + "system": ( + "Generate a concise, sentence-case title (3-7 words). " + 'Return JSON with a single "title" field.' + ), + "messages": [{"role": "user", "content": "hello"}], + }, + "Conversation", + ), + ( + "prefix", + { + "model": "claude-3-5-sonnet-20241022", + "messages": [ + { + "role": "user", + "content": ( + "extract command\n" + "Command: git status --short" + ), + } + ], + }, + "git", + ), + ( + "suggestion", + { + "model": "claude-3-5-sonnet-20241022", + "messages": [{"role": "user", "content": "[SUGGESTION MODE: next]"}], + }, + "", + ), + ( + "filepath", + { + "model": "claude-3-5-sonnet-20241022", + "system": "Extract any file paths that this command output contains.", + "messages": [ + { + "role": "user", + "content": ( + "Command: cat smoke/product/test_api_product_live.py\n" + "Output: file contents\n" + ), + } + ], + }, + "smoke/product/test_api_product_live.py", + ), + ) + for name, payload, expected_text in cases: + response = httpx.post( + f"{smoke_server.base_url}/v1/messages", + headers=smoke_headers, + json=payload, + timeout=smoke_config.timeout_s, + ) + assert response.status_code == 200, (name, response.text) + text = response.json()["content"][0]["text"] + assert expected_text in text + + +def test_api_error_shape_e2e( + smoke_server, + smoke_config: SmokeConfig, + smoke_headers: dict[str, str], +) -> None: + response = httpx.post( + f"{smoke_server.base_url}/v1/messages", + headers=smoke_headers, + json={"model": "claude-3-5-sonnet-20241022", "messages": []}, + timeout=smoke_config.timeout_s, + ) + assert response.status_code == 400 + payload = response.json() + assert payload["type"] == "error" + assert payload["error"]["type"] == "invalid_request_error" + + +def test_api_stop_e2e(smoke_server, smoke_headers: dict[str, str]) -> None: + response = httpx.post( + f"{smoke_server.base_url}/stop", + headers=smoke_headers, + timeout=5, + ) + assert response.status_code == 503 + assert response.json()["detail"] == "Messaging system not initialized" diff --git a/smoke/product/test_auth_product_live.py b/smoke/product/test_auth_product_live.py new file mode 100644 index 0000000..6bd259c --- /dev/null +++ b/smoke/product/test_auth_product_live.py @@ -0,0 +1,51 @@ +import httpx +import pytest + +from smoke.lib.config import SmokeConfig +from smoke.lib.e2e import SmokeServerDriver + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("auth")] + + +def test_api_auth_header_variants_e2e(smoke_config: SmokeConfig, tmp_path) -> None: + token = "product-smoke-token" + env_file = tmp_path / "auth-product.env" + env_file.write_text(f'ANTHROPIC_AUTH_TOKEN="{token}"\n', encoding="utf-8") + with SmokeServerDriver( + smoke_config, + name="product-auth", + env_overrides={ + "ANTHROPIC_AUTH_TOKEN": token, + "FCC_ENV_FILE": str(env_file), + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + unauth = httpx.get( + f"{server.base_url}/v1/models", timeout=smoke_config.timeout_s + ) + x_api_key = httpx.get( + f"{server.base_url}/v1/models", + headers={"x-api-key": token}, + timeout=smoke_config.timeout_s, + ) + bearer = httpx.get( + f"{server.base_url}/v1/models", + headers={"authorization": f"Bearer {token}"}, + timeout=smoke_config.timeout_s, + ) + anthropic = httpx.get( + f"{server.base_url}/v1/models", + headers={"anthropic-auth-token": token}, + timeout=smoke_config.timeout_s, + ) + invalid = httpx.get( + f"{server.base_url}/v1/models", + headers={"x-api-key": "wrong"}, + timeout=smoke_config.timeout_s, + ) + + assert unauth.status_code == 401 + assert x_api_key.status_code == 200 + assert bearer.status_code == 200 + assert anthropic.status_code == 200 + assert invalid.status_code == 401 diff --git a/smoke/product/test_cli_package_product_live.py b/smoke/product/test_cli_package_product_live.py new file mode 100644 index 0000000..cf269c9 --- /dev/null +++ b/smoke/product/test_cli_package_product_live.py @@ -0,0 +1,116 @@ +import asyncio +import os +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager +from free_claude_code.cli.managed.session import ManagedClaudeSession +from free_claude_code.core.version import package_version +from smoke.lib.child_process import cmd_fcc_init, cmd_fcc_version, run_captured_text +from smoke.lib.config import SmokeConfig + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("cli")] + + +def test_entrypoint_init_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None: + env = os.environ.copy() + env["HOME"] = str(tmp_path) + env["USERPROFILE"] = str(tmp_path) + result = run_captured_text( + cmd_fcc_init(), + cwd=smoke_config.root, + env=env, + timeout=smoke_config.timeout_s, + check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout + env_file = tmp_path / ".fcc" / ".env" + assert env_file.is_file() + assert env_file.read_text(encoding="utf-8").strip() + + +def test_entrypoint_version_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None: + env = os.environ.copy() + env["HOME"] = str(tmp_path) + env["USERPROFILE"] = str(tmp_path) + + result = run_captured_text( + cmd_fcc_version(), + cwd=smoke_config.root, + env=env, + timeout=smoke_config.timeout_s, + check=False, + ) + + assert result.returncode == 0 + assert result.stdout == f"free-claude-code {package_version()}\n" + assert result.stderr == "" + assert not (tmp_path / ".fcc" / ".env").exists() + + +@pytest.mark.asyncio +async def test_cli_session_resume_fork_e2e(tmp_path: Path) -> None: + session = ManagedClaudeSession(str(tmp_path), "http://127.0.0.1:8082") + process = AsyncMock() + process.stdout.read.side_effect = [b""] + process.stderr.read.return_value = b"" + process.wait.return_value = 0 + process.returncode = 0 + + with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as spawn: + spawn.return_value = process + async for _event in session.start_task( + "resume this", + session_id="sess_product", + fork_session=True, + ): + pass + + args = spawn.call_args[0] + assert args[:3] == ("claude", "--resume", "sess_product") + assert "--fork-session" in args + assert "-p" in args + assert "resume this" in args + + +@pytest.mark.asyncio +async def test_cli_process_cleanup_e2e(tmp_path: Path) -> None: + manager = ManagedClaudeSessionManager( + workspace_path=str(tmp_path), + proxy_root_url="http://127.0.0.1:8082", + ) + session, pending_id, is_new = await manager.get_or_create_session() + assert is_new is True + assert pending_id.startswith("pending_") + + mocked_stop = AsyncMock(return_value=True) + with patch.object(session, "stop", mocked_stop): + await manager.stop_all() + + mocked_stop.assert_awaited_once() + assert manager.get_stats() == { + "active_sessions": 0, + "pending_sessions": 0, + "busy_count": 0, + } + + +@pytest.mark.asyncio +async def test_cli_session_stop_kills_child_e2e(tmp_path: Path) -> None: + session = ManagedClaudeSession(str(tmp_path), "http://127.0.0.1:8082") + process = MagicMock() + process.pid = 123456 + process.returncode = None + process.wait = AsyncMock(side_effect=[asyncio.TimeoutError, 0]) + session.process = process + + with patch( + "free_claude_code.cli.managed.session.kill_pid_tree_best_effort" + ) as kill_tree: + stopped = await session.stop() + + assert stopped is True + kill_tree.assert_called_once_with(process.pid) + process.kill.assert_called_once() diff --git a/smoke/product/test_client_product_live.py b/smoke/product/test_client_product_live.py new file mode 100644 index 0000000..fec8f66 --- /dev/null +++ b/smoke/product/test_client_product_live.py @@ -0,0 +1,286 @@ +import json +import os +import shutil +import subprocess +import threading +from collections.abc import Iterator +from contextlib import contextmanager +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +from smoke.lib.config import SmokeConfig +from smoke.lib.e2e import ( + ClientProtocolDriver, + ConversationDriver, + ProviderMatrixDriver, + SmokeServerDriver, + assert_product_stream, +) + +pytestmark = [pytest.mark.live] + + +@pytest.mark.smoke_target("clients") +def test_vscode_protocol_e2e(smoke_config: SmokeConfig) -> None: + provider_model = ProviderMatrixDriver(smoke_config).first_model() + with SmokeServerDriver( + smoke_config, + name="product-vscode", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + turn = ConversationDriver(server, smoke_config).stream( + ClientProtocolDriver.adaptive_thinking_payload(), + headers=ClientProtocolDriver.vscode_headers(), + ) + + assert_product_stream(turn.events) + + +@pytest.mark.smoke_target("clients") +def test_jetbrains_protocol_e2e(smoke_config: SmokeConfig) -> None: + provider_model = ProviderMatrixDriver(smoke_config).first_model() + with SmokeServerDriver( + smoke_config, + name="product-jetbrains", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + driver = ConversationDriver(server, smoke_config) + first = driver.stream( + ClientProtocolDriver.tool_result_payload(), + headers=ClientProtocolDriver.jetbrains_headers(smoke_config), + ) + + assert_product_stream(first.events) + + +@pytest.mark.smoke_target("clients") +def test_pi_cli_prompt_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None: + if not shutil.which("pi"): + pytest.skip("missing_env: Pi CLI not found") + uv_bin = shutil.which("uv") + if not uv_bin: + pytest.skip("missing_env: uv not found") + provider_model = ProviderMatrixDriver(smoke_config).first_model() + auth_token = "fcc-pi-smoke-token" + + with SmokeServerDriver( + smoke_config, + name="product-pi-cli", + env_overrides={ + "MODEL": provider_model.full_model, + "ANTHROPIC_AUTH_TOKEN": auth_token, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + env = os.environ.copy() + env.update( + { + "HOST": "127.0.0.1", + "PORT": str(server.port), + "FCC_OPEN_BROWSER": "0", + "ANTHROPIC_AUTH_TOKEN": auth_token, + "PI_CODING_AGENT_DIR": str(tmp_path / "pi-agent"), + } + ) + result = subprocess.run( + [ + uv_bin, + "run", + "--project", + str(smoke_config.root), + "--no-sync", + "fcc-pi", + "--no-session", + "--no-approve", + "--print", + "Reply with exactly FCC_SMOKE_PI", + ], + cwd=tmp_path, + env=env, + check=False, + capture_output=True, + text=True, + timeout=smoke_config.timeout_s + 15, + ) + server_log = server.log_path.read_text(encoding="utf-8", errors="replace") + + assert result.returncode == 0, result.stderr or result.stdout + assert "FCC_SMOKE_PI" in result.stdout + assert "POST /v1/messages" in server_log + + +@pytest.mark.smoke_target("cli") +def test_claude_cli_adaptive_thinking_e2e( + smoke_config: SmokeConfig, tmp_path: Path +) -> None: + claude_bin = shutil.which(smoke_config.claude_bin) + if not claude_bin: + pytest.skip(f"missing_env: Claude CLI not found: {smoke_config.claude_bin}") + provider_model = ProviderMatrixDriver(smoke_config).first_model() + + with SmokeServerDriver( + smoke_config, + name="product-claude-cli-adaptive", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + result = ClientProtocolDriver.run_claude_prompt( + claude_bin=claude_bin, + server=server, + config=smoke_config, + cwd=tmp_path, + prompt="think hard, then reply with exactly FCC_SMOKE_CLI", + ) + server_log = server.log_path.read_text(encoding="utf-8", errors="replace") + + assert result.returncode == 0, result.stderr or result.stdout + assert "POST /v1/messages" in server_log + assert " 422 " not in server_log + assert 'HTTP/1.1" 422' not in server_log + assert "400 Bad Request" not in result.stdout + assert "FCC_SMOKE_CLI" in result.stdout + + +@pytest.mark.smoke_target("cli") +def test_claude_cli_provider_error_e2e( + smoke_config: SmokeConfig, tmp_path: Path +) -> None: + claude_bin = shutil.which(smoke_config.claude_bin) + if not claude_bin: + pytest.skip(f"missing_env: Claude CLI not found: {smoke_config.claude_bin}") + broken_model = "lmstudio/fcc-smoke-failing-model" + + with ( + _deliberately_failing_openai_provider() as ( + provider_base_url, + provider_requests, + ), + SmokeServerDriver( + smoke_config, + name="product-claude-cli-provider-error", + env_overrides={ + "MODEL": broken_model, + "MODEL_OPUS": broken_model, + "MODEL_SONNET": broken_model, + "MODEL_HAIKU": broken_model, + "LM_STUDIO_BASE_URL": provider_base_url, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server, + ): + result = ClientProtocolDriver.run_claude_prompt( + claude_bin=claude_bin, + server=server, + config=smoke_config, + cwd=tmp_path, + prompt="Reply with exactly FCC_SMOKE_UNREACHABLE.", + model="claude-sonnet-4-5-20250929", + ) + server_log = server.log_path.read_text(encoding="utf-8", errors="replace") + + combined = f"{result.stdout}\n{result.stderr}" + lower = combined.lower() + downstream_requests = sum( + "POST /v1/messages" in line and "HTTP/1.1" in line + for line in server_log.splitlines() + ) + + assert result.returncode != 0 + assert "empty or malformed" not in lower + assert "proxy or gateway intercepting" not in lower + assert "api error" in lower or "selected model" in lower + assert "fcc smoke provider rejected the request deliberately" in lower + assert downstream_requests == 1, server_log + assert provider_requests == ["/v1/chat/completions"] + + +@contextmanager +def _deliberately_failing_openai_provider() -> Iterator[tuple[str, list[str]]]: + provider_requests: list[str] = [] + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + if self.path == "/v1/models": + self._write_json( + HTTPStatus.OK, + { + "object": "list", + "data": [ + { + "id": "fcc-smoke-failing-model", + "object": "model", + } + ], + }, + ) + return + if self.path == "/api/v0/models": + self._write_json(HTTPStatus.OK, {"data": []}) + return + self._write_json(HTTPStatus.NOT_FOUND, {"error": "not found"}) + + def do_POST(self) -> None: + length = int(self.headers.get("content-length", "0")) + self.rfile.read(length) + provider_requests.append(self.path) + self._write_json( + HTTPStatus.BAD_REQUEST, + { + "error": { + "type": "invalid_request_error", + "code": "fcc_smoke_failure", + "message": "FCC smoke provider rejected the request deliberately.", + } + }, + ) + + def log_message(self, format: str, *args: object) -> None: + return + + def _write_json(self, status: HTTPStatus, payload: object) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + port = int(server.server_address[1]) + yield f"http://127.0.0.1:{port}/v1", provider_requests + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +@pytest.mark.smoke_target("cli") +def test_claude_cli_multiturn_tool_protocol_e2e(smoke_config: SmokeConfig) -> None: + provider_model = ProviderMatrixDriver(smoke_config).first_model() + with SmokeServerDriver( + smoke_config, + name="product-claude-cli-protocol", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + turn = ConversationDriver(server, smoke_config).stream( + ClientProtocolDriver.tool_result_payload() + ) + + assert_product_stream(turn.events) diff --git a/smoke/product/test_config_extensibility_product_live.py b/smoke/product/test_config_extensibility_product_live.py new file mode 100644 index 0000000..7461574 --- /dev/null +++ b/smoke/product/test_config_extensibility_product_live.py @@ -0,0 +1,176 @@ +import os + +import pytest + +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.config.settings import Settings +from free_claude_code.messaging.platforms.factory import create_messaging_components +from free_claude_code.providers.runtime import build_provider_config +from smoke.lib.child_process import ( + cmd_free_claude_code_serve, + cmd_python_c, + run_captured_text, +) +from smoke.lib.config import SmokeConfig +from smoke.lib.e2e import SmokeServerDriver + +pytestmark = [pytest.mark.live] + + +@pytest.mark.smoke_target("config") +def test_env_precedence_e2e(smoke_config: SmokeConfig, tmp_path) -> None: + env_file = tmp_path / "product.env" + env_file.write_text( + 'MODEL="open_router/test/model"\nANTHROPIC_AUTH_TOKEN="dotenv-token"\n', + encoding="utf-8", + ) + env = os.environ.copy() + env["FCC_ENV_FILE"] = str(env_file) + env["MODEL"] = "nvidia_nim/process-model" + env["ANTHROPIC_AUTH_TOKEN"] = "process-token" + script = ( + "from free_claude_code.config.settings import get_settings; " + "s=get_settings(); " + "print(s.model); print(s.anthropic_auth_token)" + ) + result = run_captured_text( + cmd_python_c(script), + cwd=smoke_config.root, + env=env, + timeout=smoke_config.timeout_s, + check=False, + ) + assert result.returncode == 0, result.stderr + lines = result.stdout.splitlines() + assert lines == ["nvidia_nim/process-model", "dotenv-token"] + + +@pytest.mark.smoke_target("config") +def test_removed_env_migration_e2e(smoke_config: SmokeConfig, tmp_path) -> None: + env_file = tmp_path / "removed.env" + env_file.write_text('NIM_ENABLE_THINKING="true"\n', encoding="utf-8") + env = os.environ.copy() + env["FCC_ENV_FILE"] = str(env_file) + result = run_captured_text( + cmd_python_c( + "from free_claude_code.config.settings import Settings; Settings()" + ), + cwd=smoke_config.root, + env=env, + timeout=smoke_config.timeout_s, + check=False, + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.smoke_target("config") +def test_per_model_thinking_config_e2e(smoke_config: SmokeConfig, tmp_path) -> None: + env_file = tmp_path / "thinking.env" + env_file.write_text( + 'ENABLE_MODEL_THINKING="false"\n' + 'ENABLE_OPUS_THINKING="true"\n' + "ENABLE_SONNET_THINKING=\n" + 'ENABLE_HAIKU_THINKING="false"\n', + encoding="utf-8", + ) + env = os.environ.copy() + env["FCC_ENV_FILE"] = str(env_file) + script = ( + "from free_claude_code.application.routing import ModelRouter; " + "from free_claude_code.config.settings import Settings; " + "s=Settings(); " + "r=ModelRouter(s); " + "print(r.resolve('claude-opus-4-20250514').thinking_enabled); " + "print(r.resolve('claude-sonnet-4-20250514').thinking_enabled); " + "print(r.resolve('claude-haiku-4-20250514').thinking_enabled); " + "print(r.resolve('unknown-model').thinking_enabled)" + ) + result = run_captured_text( + cmd_python_c(script), + cwd=smoke_config.root, + env=env, + timeout=smoke_config.timeout_s, + check=False, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == ["True", "False", "False", "False"] + + +@pytest.mark.smoke_target("config") +def test_proxy_timeout_config_e2e(smoke_config: SmokeConfig, tmp_path) -> None: + env_file = tmp_path / "timeouts.env" + env_file.write_text( + 'MODEL="open_router/test/model"\n' + 'OPENROUTER_API_KEY="key"\n' + 'OPENROUTER_PROXY="socks5://127.0.0.1:9999"\n' + 'HTTP_READ_TIMEOUT="321"\n' + 'HTTP_CONNECT_TIMEOUT="7"\n' + 'HTTP_WRITE_TIMEOUT="8"\n', + encoding="utf-8", + ) + env = os.environ.copy() + env["FCC_ENV_FILE"] = str(env_file) + script = ( + "from free_claude_code.config.settings import Settings; " + "from free_claude_code.config.provider_catalog import PROVIDER_CATALOG; " + "from free_claude_code.providers.runtime import build_provider_config; " + "s=Settings(); c=build_provider_config(PROVIDER_CATALOG['open_router'], s); " + "print(c.proxy); print(c.http_read_timeout); " + "print(c.http_connect_timeout); print(c.http_write_timeout)" + ) + result = run_captured_text( + cmd_python_c(script), + cwd=smoke_config.root, + env=env, + timeout=smoke_config.timeout_s, + check=False, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines() == [ + "socks5://127.0.0.1:9999", + "321.0", + "7.0", + "8.0", + ] + + +@pytest.mark.smoke_target("extensibility") +def test_provider_runtime_config_e2e() -> None: + settings_kwargs: dict[str, str] = {} + for descriptor in PROVIDER_CATALOG.values(): + if descriptor.credential_attr is not None: + settings_kwargs[_settings_init_key(descriptor.credential_attr)] = ( + f"{descriptor.provider_id}-key" + ) + if descriptor.base_url_attr is not None and descriptor.default_base_url: + settings_kwargs[_settings_init_key(descriptor.base_url_attr)] = ( + descriptor.default_base_url + ) + settings = Settings.model_validate(settings_kwargs) + for descriptor in PROVIDER_CATALOG.values(): + config = build_provider_config(descriptor, settings) + assert config.base_url + assert config.api_key + + +def _settings_init_key(field_name: str) -> str: + alias = Settings.model_fields[field_name].validation_alias + return alias if isinstance(alias, str) else field_name + + +@pytest.mark.smoke_target("extensibility") +def test_platform_factory_e2e() -> None: + assert create_messaging_components("not-a-platform") is None + assert create_messaging_components("telegram") is None + assert create_messaging_components("discord") is None + + +@pytest.mark.smoke_target("cli") +def test_entrypoint_server_e2e(smoke_config: SmokeConfig) -> None: + with SmokeServerDriver( + smoke_config, + name="product-entrypoint", + command=cmd_free_claude_code_serve(), + env_overrides={"MESSAGING_PLATFORM": "none"}, + ).run() as server: + assert server.process.poll() is None diff --git a/smoke/product/test_live_platform_product_live.py b/smoke/product/test_live_platform_product_live.py new file mode 100644 index 0000000..7c72784 --- /dev/null +++ b/smoke/product/test_live_platform_product_live.py @@ -0,0 +1,125 @@ +import os +import time + +import httpx +import pytest + +from smoke.lib.config import SmokeConfig + +pytestmark = [pytest.mark.live] + + +@pytest.mark.smoke_target("telegram") +def test_telegram_live_permissions_e2e(smoke_config: SmokeConfig) -> None: + token = smoke_config.settings.telegram_bot_token + if not token: + pytest.skip("missing_env: TELEGRAM_BOT_TOKEN is not configured") + + base_url = f"https://api.telegram.org/bot{token}" + get_me = httpx.get(f"{base_url}/getMe", timeout=smoke_config.timeout_s) + assert get_me.status_code == 200, get_me.text + assert get_me.json()["ok"] is True + + chat_id = os.getenv("FCC_SMOKE_TELEGRAM_CHAT_ID") or ( + smoke_config.settings.allowed_telegram_user_id or "" + ) + if not chat_id: + pytest.skip( + "missing_env: FCC_SMOKE_TELEGRAM_CHAT_ID or " + "ALLOWED_TELEGRAM_USER_ID required" + ) + + marker = f"FCC product smoke {int(time.time())}" + sent = httpx.post( + f"{base_url}/sendMessage", + json={"chat_id": chat_id, "text": marker}, + timeout=smoke_config.timeout_s, + ) + assert sent.status_code == 200, sent.text + message_id = sent.json()["result"]["message_id"] + + edited = httpx.post( + f"{base_url}/editMessageText", + json={"chat_id": chat_id, "message_id": message_id, "text": marker + " edit"}, + timeout=smoke_config.timeout_s, + ) + assert edited.status_code == 200, edited.text + + deleted = httpx.post( + f"{base_url}/deleteMessage", + json={"chat_id": chat_id, "message_id": message_id}, + timeout=smoke_config.timeout_s, + ) + assert deleted.status_code == 200, deleted.text + + +@pytest.mark.smoke_target("discord") +def test_discord_live_permissions_e2e(smoke_config: SmokeConfig) -> None: + token = smoke_config.settings.discord_bot_token + channel_id = os.getenv("FCC_SMOKE_DISCORD_CHANNEL_ID") + if not channel_id and smoke_config.settings.allowed_discord_channels: + channel_id = smoke_config.settings.allowed_discord_channels.split(",", 1)[0] + if not token: + pytest.skip("missing_env: DISCORD_BOT_TOKEN is not configured") + if not channel_id: + pytest.skip( + "missing_env: FCC_SMOKE_DISCORD_CHANNEL_ID or " + "ALLOWED_DISCORD_CHANNELS required" + ) + + headers = {"authorization": f"Bot {token}"} + base_url = "https://discord.com/api/v10" + + channel = httpx.get( + f"{base_url}/channels/{channel_id}", + headers=headers, + timeout=smoke_config.timeout_s, + ) + assert channel.status_code == 200, channel.text + + marker = f"FCC product smoke {int(time.time())}" + sent = httpx.post( + f"{base_url}/channels/{channel_id}/messages", + headers=headers, + json={"content": marker}, + timeout=smoke_config.timeout_s, + ) + assert sent.status_code == 200, sent.text + message_id = sent.json()["id"] + + edited = httpx.patch( + f"{base_url}/channels/{channel_id}/messages/{message_id}", + headers=headers, + json={"content": marker + " edit"}, + timeout=smoke_config.timeout_s, + ) + assert edited.status_code == 200, edited.text + + deleted = httpx.delete( + f"{base_url}/channels/{channel_id}/messages/{message_id}", + headers=headers, + timeout=smoke_config.timeout_s, + ) + assert deleted.status_code in {200, 204}, deleted.text + + +@pytest.mark.smoke_target("telegram") +def test_telegram_live_manual_inbound_e2e(smoke_config: SmokeConfig) -> None: + if not smoke_config.interactive: + pytest.skip("missing_env: set FCC_SMOKE_INTERACTIVE=1 for manual inbound smoke") + pytest.skip( + "manual product smoke: send the printed nonce to Telegram and verify " + "progress edits, final transcript, /stop, /clear, /stats, and voice note " + "behavior from the real client" + ) + + +@pytest.mark.smoke_target("discord") +def test_discord_live_manual_inbound_e2e(smoke_config: SmokeConfig) -> None: + if not smoke_config.interactive: + pytest.skip("missing_env: set FCC_SMOKE_INTERACTIVE=1 for manual inbound smoke") + pytest.skip( + "manual product smoke: send the printed nonce to Discord and verify " + "progress edits, final transcript, /stop, /clear, /stats, and voice note " + "behavior from the real client" + ) diff --git a/smoke/product/test_local_provider_product_live.py b/smoke/product/test_local_provider_product_live.py new file mode 100644 index 0000000..c572a67 --- /dev/null +++ b/smoke/product/test_local_provider_product_live.py @@ -0,0 +1,58 @@ +import pytest + +from smoke.lib.config import SmokeConfig +from smoke.lib.e2e import ConversationDriver, SmokeServerDriver, assert_product_stream +from smoke.lib.local_providers import first_local_provider_model_id + +pytestmark = [pytest.mark.live] + + +@pytest.mark.smoke_target("lmstudio") +def test_lmstudio_messages_e2e(smoke_config: SmokeConfig) -> None: + _local_provider_messages_e2e( + smoke_config, + provider="lmstudio", + base_url=smoke_config.settings.lm_studio_base_url, + ) + + +@pytest.mark.smoke_target("llamacpp") +def test_llamacpp_openai_chat_e2e(smoke_config: SmokeConfig) -> None: + _local_provider_messages_e2e( + smoke_config, + provider="llamacpp", + base_url=smoke_config.settings.llamacpp_base_url, + ) + + +@pytest.mark.smoke_target("ollama") +def test_ollama_openai_chat_e2e(smoke_config: SmokeConfig) -> None: + _local_provider_messages_e2e( + smoke_config, + provider="ollama", + base_url=smoke_config.settings.ollama_base_url, + ) + + +def _local_provider_messages_e2e( + smoke_config: SmokeConfig, + *, + provider: str, + base_url: str, +) -> None: + model_id = first_local_provider_model_id( + provider, + base_url, + timeout_s=smoke_config.timeout_s, + ) + + with SmokeServerDriver( + smoke_config, + name=f"product-{provider}-messages", + env_overrides={"MODEL": f"{provider}/{model_id}", "MESSAGING_PLATFORM": "none"}, + ).run() as server: + turn = ConversationDriver(server, smoke_config).ask( + "Reply with one short sentence." + ) + + assert_product_stream(turn.events) diff --git a/smoke/product/test_messaging_product_live.py b/smoke/product/test_messaging_product_live.py new file mode 100644 index 0000000..eb4ffd0 --- /dev/null +++ b/smoke/product/test_messaging_product_live.py @@ -0,0 +1,484 @@ +import asyncio +import json + +import pytest + +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.platforms.ports import MessagingStartupNotice +from free_claude_code.messaging.trees import MessageState, TreeIdentity +from smoke.lib.e2e import FakeCLISession, FakePlatformDriver, default_cli_events + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("messaging")] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_messaging_fake_full_flow_e2e(platform_name: str, tmp_path) -> None: + driver = FakePlatformDriver(platform_name, tmp_path) + + incoming = await driver.send("Please inspect README.", message_id="root_1") + + node = await driver.workflow.tree_queue.get_node( + incoming.scope, + incoming.message_id, + ) + assert node is not None + assert node.state == MessageState.COMPLETED + assert driver.platform.sent + assert driver.platform.edits + edit_text = "\n".join(edit["text"] for edit in driver.platform.edits) + assert "Fake platform answer" in edit_text + assert "Read" in edit_text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_messaging_subagent_control_e2e(platform_name: str, tmp_path) -> None: + task_events = [ + {"type": "session_info", "session_id": "sess_task"}, + { + "type": "assistant", + "message": { + "content": [ + {"type": "thinking", "thinking": "Need a focused worker."}, + { + "type": "tool_use", + "id": "toolu_task", + "name": "Task", + "input": {"description": "inspect", "prompt": "inspect"}, + }, + {"type": "text", "text": "Subagent result rendered."}, + ] + }, + }, + {"type": "exit", "code": 0, "stderr": None}, + ] + driver = FakePlatformDriver(platform_name, tmp_path, event_batches=[task_events]) + + await driver.send("Delegate this safely.", message_id="root_task") + + edit_text = "\n".join(edit["text"] for edit in driver.platform.edits) + assert "Subagent" in edit_text + assert "Tool calls" in edit_text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_messaging_commands_stop_clear_stats_e2e( + platform_name: str, tmp_path +) -> None: + driver = FakePlatformDriver(platform_name, tmp_path) + root = await driver.send("start work", message_id="root_1") + root_status_id = driver.platform.sent[-1]["message_id"] + + await driver.send("/stats", message_id="stats_1") + await driver.send("/stop", message_id="stop_1", reply_to=root.message_id) + await driver.send("/clear", message_id="clear_1", reply_to=root.message_id) + global_prompt = await driver.send("clear globally", message_id="global_prompt") + global_status_id = driver.platform.sent[-1]["message_id"] + await driver.send("/clear", message_id="clear_all") + + sent_text = "\n".join(sent["text"] for sent in driver.platform.sent) + deleted = {entry["message_id"] for entry in driver.platform.deletes} + assert "Stats" in sent_text + assert "Nothing to stop for that message" in sent_text + assert { + root.message_id, + root_status_id, + global_prompt.message_id, + global_status_id, + "clear_1", + "clear_all", + } <= deleted + assert driver.session_store.load_conversation_snapshot().trees == {} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_reply_clear_uses_literal_platform_subtree_e2e( + platform_name: str, + tmp_path, +) -> None: + driver = FakePlatformDriver(platform_name, tmp_path) + root = await driver.send("root", message_id="root") + root_status = driver.platform.sent[-1]["message_id"] + sibling = await driver.send( + "prompt sibling", + message_id="sibling", + reply_to=root.message_id, + ) + sibling_status = driver.platform.sent[-1]["message_id"] + descendant = await driver.send( + "status descendant", + message_id="descendant", + reply_to=root_status, + ) + descendant_status = driver.platform.sent[-1]["message_id"] + + await driver.send( + "/clear", + message_id="clear-status", + reply_to=root_status, + ) + + deleted = {entry["message_id"] for entry in driver.platform.deletes} + assert { + root_status, + descendant.message_id, + descendant_status, + "clear-status", + } <= deleted + assert {root.message_id, sibling.message_id, sibling_status}.isdisjoint(deleted) + root_view = await driver.workflow.tree_queue.get_node(root.scope, root.message_id) + sibling_view = await driver.workflow.tree_queue.get_node( + sibling.scope, sibling.message_id + ) + assert root_view is not None and root_view.state is MessageState.ERROR + assert root_view.session_id is None + assert sibling_view is not None and sibling_view.state is MessageState.COMPLETED + assert ( + await driver.workflow.tree_queue.get_node( + descendant.scope, descendant.message_id + ) + is None + ) + + await driver.send( + "fresh continuation", + message_id="fresh", + reply_to=root.message_id, + ) + fresh_call = next( + call + for session in driver.cli_manager.sessions + for call in session.calls + if call["prompt"] == "fresh continuation" + ) + assert fresh_call["session_id"] is None + + +@pytest.mark.asyncio +async def test_messaging_startup_notice_is_clearable_e2e(tmp_path) -> None: + first_driver = FakePlatformDriver("telegram", tmp_path) + scope = MessageScope(platform="telegram", chat_id="chat_1") + + await first_driver.workflow.publish_startup_notice( + MessagingStartupNotice( + chat_id=scope.chat_id, + transport_label="Bot API", + ) + ) + first_startup_id = first_driver.platform.sent[-1]["message_id"] + first_driver.session_store.flush_pending_save() + + driver = FakePlatformDriver("telegram", tmp_path) + await driver.platform.send_message("untracked", "advance fake message ID") + await driver.workflow.publish_startup_notice( + MessagingStartupNotice( + chat_id=scope.chat_id, + transport_label="Bot API", + ) + ) + second_startup = driver.platform.sent[-1] + second_startup_id = second_startup["message_id"] + assert second_startup["text"] == ( + "🚀 *Claude Code Proxy is online\\!* \\(Bot API\\)" + ) + assert second_startup["parse_mode"] == "MarkdownV2" + assert driver.session_store.get_tracked_message_ids_for_chat( + scope.platform, scope.chat_id + ) == [first_startup_id, second_startup_id] + + await driver.send("/clear", message_id="clear_startup") + + deleted = {entry["message_id"] for entry in driver.platform.deletes} + assert {first_startup_id, second_startup_id, "clear_startup"} <= deleted + assert ( + driver.session_store.get_tracked_message_ids_for_chat( + scope.platform, scope.chat_id + ) + == [] + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_messaging_active_stop_uses_status_only_e2e( + platform_name: str, + tmp_path, + monkeypatch, +) -> None: + driver = FakePlatformDriver(platform_name, tmp_path) + started = asyncio.Event() + + class GatedActiveSession(FakeCLISession): + async def start_task( + self, + prompt: str, + session_id: str | None = None, + fork_session: bool = False, + ): + self.calls.append( + { + "prompt": prompt, + "session_id": session_id, + "fork_session": fork_session, + } + ) + self.is_busy = True + try: + yield { + "type": "assistant", + "message": { + "content": [{"type": "text", "text": "partial answer"}] + }, + } + started.set() + await asyncio.Event().wait() + finally: + self.is_busy = False + + async def controlled_session(session_id: str | None = None): + session = GatedActiveSession([]) + driver.cli_manager.sessions.append(session) + return session, session_id or "pending_0", session_id is None + + monkeypatch.setattr( + driver.cli_manager, + "get_or_create_session", + controlled_session, + ) + root = await driver.emit("active work", message_id="root_active") + await started.wait() + status_id = driver.platform.sent[-1]["message_id"] + sent_before_stop = len(driver.platform.sent) + + await driver.emit( + "/stop", + message_id="stop_active", + reply_to=root.message_id, + ) + await driver.wait_for_idle() + + assert len(driver.platform.sent) == sent_before_stop + stopped_edits = [ + edit + for edit in driver.platform.edits + if edit["message_id"] == status_id and "Stopped" in edit["text"] + ] + assert stopped_edits + assert "partial answer" in stopped_edits[-1]["text"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_tree_threading_e2e(platform_name: str, tmp_path) -> None: + batches = [default_cli_events("sess_root"), default_cli_events("sess_branch")] + driver = FakePlatformDriver(platform_name, tmp_path, event_batches=batches) + + root = await driver.send("root prompt", message_id="root_1") + branch = await driver.send( + "branch prompt", message_id="branch_1", reply_to=root.message_id + ) + + branch_node = await driver.workflow.tree_queue.get_node( + branch.scope, + branch.message_id, + ) + assert branch_node is not None + assert branch_node.parent_id == root.message_id + assert driver.cli_manager.sessions[1].calls[0]["session_id"] == "sess_root" + assert driver.cli_manager.sessions[1].calls[0]["fork_session"] is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_messaging_queued_scoped_cancel_e2e( + platform_name: str, + tmp_path, + monkeypatch, +) -> None: + driver = FakePlatformDriver(platform_name, tmp_path) + root_started = asyncio.Event() + release_root = asyncio.Event() + + class GatedRootSession(FakeCLISession): + async def start_task( + self, + prompt: str, + session_id: str | None = None, + fork_session: bool = False, + ): + self.calls.append( + { + "prompt": prompt, + "session_id": session_id, + "fork_session": fork_session, + } + ) + self.is_busy = True + root_started.set() + try: + await release_root.wait() + for event in self.events: + await asyncio.sleep(0) + yield event + finally: + self.is_busy = False + + async def controlled_session(session_id: str | None = None): + index = len(driver.cli_manager.sessions) + events = default_cli_events(f"sess_{index}") + session = GatedRootSession(events) if index == 0 else FakeCLISession(events) + driver.cli_manager.sessions.append(session) + return session, session_id or f"pending_{index}", session_id is None + + monkeypatch.setattr( + driver.cli_manager, + "get_or_create_session", + controlled_session, + ) + + root = await driver.emit("root", message_id="root") + await root_started.wait() + cancelled = await driver.emit( + "cancel me", + message_id="cancelled", + reply_to=root.message_id, + ) + cancelled_status_id = driver.platform.sent[-1]["message_id"] + survivor = await driver.emit( + "run me", + message_id="survivor", + reply_to=root.message_id, + ) + sent_before_stop = len(driver.platform.sent) + await driver.emit( + "/stop", + message_id="stop-cancelled", + reply_to=cancelled.message_id, + ) + assert len(driver.platform.sent) == sent_before_stop + + release_root.set() + await driver.wait_for_idle() + + prompts = [ + call["prompt"] + for session in driver.cli_manager.sessions + for call in session.calls + ] + assert prompts == ["root", "run me"] + cancelled_view = await driver.workflow.tree_queue.get_node( + cancelled.scope, + cancelled.message_id, + ) + survivor_view = await driver.workflow.tree_queue.get_node( + survivor.scope, + survivor.message_id, + ) + assert cancelled_view is not None + assert cancelled_view.state is MessageState.ERROR + assert survivor_view is not None + assert survivor_view.state is MessageState.COMPLETED + + rendered = "\n".join( + entry["text"] for entry in driver.platform.sent + driver.platform.edits + ) + assert "position 1" in rendered + assert "position 2" in rendered + assert "Stopped" in rendered + assert any( + edit["message_id"] == cancelled_status_id and "Stopped" in edit["text"] + for edit in driver.platform.edits + ) + driver.session_store.flush_pending_save() + persisted = driver.session_store.load_conversation_snapshot() + root_identity = TreeIdentity(scope=root.scope, root_id=root.message_id) + assert persisted.trees[root_identity].nodes["survivor"]["state"] == "completed" + + +@pytest.mark.asyncio +async def test_restart_restore_and_session_persistence_e2e(tmp_path) -> None: + first = FakePlatformDriver("telegram", tmp_path) + root = await first.send("persist me", message_id="root_1") + first.session_store.flush_pending_save() + + session_file = tmp_path / "telegram-sessions.json" + payload = json.loads(session_file.read_text(encoding="utf-8")) + assert payload["conversation"]["trees"] + assert payload["managed_messages"] + + restored = FakePlatformDriver("telegram", tmp_path) + restored.platform.continue_message_sequence_after(first.platform) + restored.workflow.restore() + saved = restored.session_store.load_conversation_snapshot() + assert saved.trees + identity = TreeIdentity(scope=root.scope, root_id=root.message_id) + assert saved.get_tree(identity) is not None + + reply = await restored.send( + "continue from disk", + message_id="reply_1", + reply_to=root.message_id, + ) + reply_view = await restored.workflow.tree_queue.get_node( + reply.scope, + reply.message_id, + ) + assert reply_view is not None + assert reply_view.parent_id == root.message_id + call = restored.cli_manager.sessions[0].calls[0] + assert call["session_id"] == "fake_session_1" + assert call["fork_session"] is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_same_message_ids_are_isolated_by_chat_e2e( + platform_name: str, + tmp_path, +) -> None: + driver = FakePlatformDriver(platform_name, tmp_path) + first = await driver.send("first chat", chat_id="chat_a", message_id="42") + second = await driver.send("second chat", chat_id="chat_b", message_id="42") + + snapshot = await driver.workflow.tree_queue.snapshot() + assert set(snapshot.trees) == { + TreeIdentity(scope=first.scope, root_id="42"), + TreeIdentity(scope=second.scope, root_id="42"), + } + + reply = await driver.send( + "first chat reply", + chat_id="chat_a", + message_id="43", + reply_to="42", + ) + reply_view = await driver.workflow.tree_queue.get_node(reply.scope, "43") + assert reply_view is not None + assert reply_view.parent_id == "42" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("platform_name", ["discord", "telegram"]) +async def test_voice_platform_fake_e2e(platform_name: str, tmp_path) -> None: + driver = FakePlatformDriver(platform_name, tmp_path) + driver.platform.seed_pending_voice("chat_1", "voice_msg_1", "voice_status_1") + + await driver.send("/clear", message_id="clear_voice", reply_to="voice_msg_1") + + driver.platform.seed_pending_voice("chat_1", "voice_msg_2", "voice_status_2") + sent_before_stop = len(driver.platform.sent) + await driver.send("/stop", message_id="stop_voice") + + deleted = {entry["message_id"] for entry in driver.platform.deletes} + assert {"voice_msg_1", "voice_status_1", "clear_voice"} <= deleted + assert driver.platform.pending_voice_count == 0 + sent_text = "\n".join(sent["text"] for sent in driver.platform.sent) + assert "Voice note cancelled" not in sent_text + assert len(driver.platform.sent) == sent_before_stop + assert any( + edit["message_id"] == "voice_status_2" and "Stopped" in edit["text"] + for edit in driver.platform.edits + ) diff --git a/smoke/product/test_nvidia_nim_cli_product_live.py b/smoke/product/test_nvidia_nim_cli_product_live.py new file mode 100644 index 0000000..910c379 --- /dev/null +++ b/smoke/product/test_nvidia_nim_cli_product_live.py @@ -0,0 +1,68 @@ +import shutil +from pathlib import Path + +import pytest + +from smoke.lib.claude_cli_matrix import ( + CliMatrixOutcome, + regression_failures, + run_cli_feature_probes, + write_matrix_report, +) +from smoke.lib.config import SmokeConfig +from smoke.lib.e2e import SmokeServerDriver + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("nvidia_nim_cli")] + + +def test_nvidia_nim_cli_matrix_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None: + if not smoke_config.has_provider_configuration("nvidia_nim"): + pytest.skip("missing_env: NVIDIA_NIM_API_KEY is not configured") + + claude_bin = shutil.which(smoke_config.claude_bin) + if not claude_bin: + pytest.skip(f"missing_env: Claude CLI not found: {smoke_config.claude_bin}") + + provider_models = smoke_config.nvidia_nim_cli_models() + if not provider_models: + pytest.skip("missing_env: no NVIDIA NIM CLI smoke models configured") + + outcomes: list[CliMatrixOutcome] = [] + for provider_model in provider_models: + with SmokeServerDriver( + smoke_config, + name=f"product-nvidia-nim-cli-{_slug(provider_model.model_name)}", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + "ENABLE_MODEL_THINKING": "true", + "LOG_RAW_API_PAYLOADS": "true", + "LOG_RAW_SSE_EVENTS": "true", + }, + ).run() as server: + outcomes.extend( + run_cli_feature_probes( + claude_bin=claude_bin, + server=server, + smoke_config=smoke_config, + provider_model=provider_model, + model_dir=tmp_path / _slug(provider_model.model_name), + marker_prefix="NIM", + ) + ) + + report_path = write_matrix_report( + smoke_config, + outcomes, + target="nvidia_nim_cli", + filename_prefix="nvidia-nim-cli", + ) + failures = regression_failures(outcomes) + assert not failures, ( + f"NVIDIA NIM CLI matrix regressions written to {report_path}:\n" + + "\n".join(failures) + ) + + +def _slug(value: str) -> str: + return "".join(char if char.isalnum() else "-" for char in value).strip("-") diff --git a/smoke/product/test_openrouter_free_cli_product_live.py b/smoke/product/test_openrouter_free_cli_product_live.py new file mode 100644 index 0000000..994803b --- /dev/null +++ b/smoke/product/test_openrouter_free_cli_product_live.py @@ -0,0 +1,70 @@ +import shutil +from pathlib import Path + +import pytest + +from smoke.lib.claude_cli_matrix import ( + CliMatrixOutcome, + regression_failures, + run_cli_feature_probes, + write_matrix_report, +) +from smoke.lib.config import SmokeConfig +from smoke.lib.e2e import SmokeServerDriver + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("openrouter_free_cli")] + + +def test_openrouter_free_cli_matrix_e2e( + smoke_config: SmokeConfig, tmp_path: Path +) -> None: + if not smoke_config.has_provider_configuration("open_router"): + pytest.skip("missing_env: OPENROUTER_API_KEY is not configured") + + claude_bin = shutil.which(smoke_config.claude_bin) + if not claude_bin: + pytest.skip(f"missing_env: Claude CLI not found: {smoke_config.claude_bin}") + + provider_models = smoke_config.openrouter_free_cli_models() + if not provider_models: + pytest.skip("missing_env: no OpenRouter free CLI smoke models configured") + + outcomes: list[CliMatrixOutcome] = [] + for provider_model in provider_models: + with SmokeServerDriver( + smoke_config, + name=f"product-openrouter-free-cli-{_slug(provider_model.model_name)}", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + "ENABLE_MODEL_THINKING": "true", + "LOG_RAW_API_PAYLOADS": "true", + "LOG_RAW_SSE_EVENTS": "true", + }, + ).run() as server: + outcomes.extend( + run_cli_feature_probes( + claude_bin=claude_bin, + server=server, + smoke_config=smoke_config, + provider_model=provider_model, + model_dir=tmp_path / _slug(provider_model.model_name), + marker_prefix="OPENROUTER_FREE", + ) + ) + + report_path = write_matrix_report( + smoke_config, + outcomes, + target="openrouter_free_cli", + filename_prefix="openrouter-free-cli", + ) + failures = regression_failures(outcomes) + assert not failures, ( + f"OpenRouter free CLI matrix regressions written to {report_path}:\n" + + "\n".join(failures) + ) + + +def _slug(value: str) -> str: + return "".join(char if char.isalnum() else "-" for char in value).strip("-") diff --git a/smoke/product/test_provider_product_live.py b/smoke/product/test_provider_product_live.py new file mode 100644 index 0000000..3006e9d --- /dev/null +++ b/smoke/product/test_provider_product_live.py @@ -0,0 +1,583 @@ +from typing import Any + +import httpx +import pytest + +from free_claude_code.application.routing import ModelRouter +from free_claude_code.core.anthropic.stream_contracts import ( + SSEEvent, + parse_sse_lines, +) +from smoke.lib.config import ProviderModel, SmokeConfig, auth_headers +from smoke.lib.e2e import ( + ConversationDriver, + ProviderMatrixDriver, + SmokeServerDriver, + assert_product_stream, + echo_tool_schema, + tool_use_blocks, +) +from smoke.lib.skips import ( + skip_if_upstream_unavailable_events, + skip_if_upstream_unavailable_exception, +) + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("providers")] + + +def test_provider_matrix_presence_e2e(smoke_config: SmokeConfig) -> None: + models = ProviderMatrixDriver(smoke_config).provider_smoke_models() + assert models or smoke_config.provider_matrix == frozenset() + + +def test_model_mapping_matrix_e2e(smoke_config: SmokeConfig) -> None: + models = ProviderMatrixDriver(smoke_config).configured_models() + sources = {model.source for model in models} + assert sources <= {"MODEL", "MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"} + for model in models: + assert model.provider + assert model.model_name + + +def test_provider_text_multiturn_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + _run_provider_scenario(smoke_config, provider_model, _scenario_text_multiturn) + + +def test_provider_adaptive_thinking_history_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + _run_provider_scenario( + smoke_config, provider_model, _scenario_adaptive_thinking_history + ) + + +def test_provider_interleaved_thinking_tool_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + _run_provider_scenario(smoke_config, provider_model, _scenario_interleaved_history) + + +@pytest.mark.smoke_target("tools") +def test_provider_tool_use_then_text_history_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + """OpenAI-compatible path: history with tool_use + assistant text after tool (issue #206).""" + _run_provider_scenario( + smoke_config, provider_model, _scenario_tool_use_then_text_in_history + ) + + +@pytest.mark.smoke_target("tools") +def test_provider_tool_result_continuation_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + _run_provider_scenario( + smoke_config, provider_model, _scenario_tool_result_continuation + ) + + +@pytest.mark.smoke_target("tools") +def test_gemini_thought_signature_tool_continuation_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + if provider_model.provider != "gemini": + pytest.skip("gemini-specific smoke scenario") + _run_provider_scenario( + smoke_config, + provider_model, + _scenario_gemini_thought_signature_tool_continuation, + ) + + +@pytest.mark.smoke_target("tools") +def test_provider_reasoning_tool_continuation_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + if not _provider_smoke_thinking_enabled(smoke_config): + pytest.skip("the configured Claude route does not enable thinking") + _run_provider_scenario( + smoke_config, provider_model, _scenario_reasoning_tool_continuation + ) + + +def test_mistral_native_reasoning_model_e2e(smoke_config: SmokeConfig) -> None: + provider_model = smoke_config.mistral_reasoning_smoke_model() + if provider_model is None: + pytest.skip("missing_env: mistral is not configured") + + payload = { + "model": "claude-opus-4-7", + "max_tokens": 256, + "messages": [{"role": "user", "content": "Reply with one short sentence."}], + "thinking": {"type": "adaptive"}, + } + with _server_for_provider( + smoke_config, provider_model, "mistral-native-reasoning" + ) as server: + turn = ConversationDriver(server, smoke_config).stream(payload) + + _assert_provider_product_stream(turn.events) + event_text = "\n".join(event.raw for event in turn.events) + assert "thinking_delta" in event_text, ( + f"{provider_model.source}={provider_model.full_model} completed without " + "native Mistral thinking output" + ) + + +@pytest.mark.smoke_target("rate_limit") +def test_provider_disconnect_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + _run_provider_scenario(smoke_config, provider_model, _scenario_disconnect) + + +def test_provider_error_e2e(smoke_config: SmokeConfig) -> None: + provider_model = ProviderMatrixDriver(smoke_config).first_model() + broken_model = f"{provider_model.provider}/fcc-smoke-missing-model" + with ( + SmokeServerDriver( + smoke_config, + name=f"product-provider-error-{provider_model.provider}", + env_overrides={"MODEL": broken_model, "MESSAGING_PLATFORM": "none"}, + ).run() as server, + httpx.Client(timeout=smoke_config.timeout_s) as client, + ): + response = client.post( + f"{server.base_url}/v1/messages", + headers=auth_headers(), + json={ + "model": "fcc-smoke-default", + "max_tokens": 32, + "messages": [{"role": "user", "content": "hello"}], + }, + ) + + assert response.status_code >= 400 + assert response.headers["content-type"].startswith("application/json") + assert response.headers["x-should-retry"] == "false" + payload = response.json() + assert payload["type"] == "error" + assert payload["error"]["type"] + assert payload["error"]["message"] + assert payload["request_id"] == response.headers["request-id"] + + +def test_provider_codex_responses_text_e2e( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + try: + with ( + _server_for_provider( + smoke_config, provider_model, "codex-responses" + ) as server, + httpx.stream( + "POST", + f"{server.base_url}/v1/responses", + headers=_openai_auth_headers(smoke_config), + json={ + "model": provider_model.full_model, + "input": smoke_config.prompt, + "max_output_tokens": 128, + "stream": True, + }, + timeout=smoke_config.timeout_s, + ) as response, + ): + assert response.status_code == 200, response.read() + events = parse_sse_lines(response.iter_lines()) + except Exception as exc: + skip_if_upstream_unavailable_exception(exc) + raise + + skip_if_upstream_unavailable_events(events) + names = [event.event for event in events] + assert names[0] == "response.created", names + assert names[-1] == "response.completed", names + assert any(event.event == "response.output_text.delta" for event in events), names + + +def test_openrouter_native_e2e(smoke_config: SmokeConfig) -> None: + models = [ + model + for model in ProviderMatrixDriver(smoke_config).provider_smoke_models() + if model.provider == "open_router" + ] + if not models: + pytest.skip("missing_env: open_router is not configured") + + provider_model = models[0] + with SmokeServerDriver( + smoke_config, + name="product-openrouter-native", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + ).run() as server: + turn = ConversationDriver(server, smoke_config).stream( + { + "model": "claude-opus-4-7", + "max_tokens": 256, + "messages": [ + { + "role": "user", + "content": "Reply with one short sentence.", + } + ], + "thinking": {"type": "adaptive", "budget_tokens": 1024}, + } + ) + _assert_provider_product_stream(turn.events) + + +def _run_provider_scenario( + smoke_config: SmokeConfig, + provider_model: ProviderModel, + scenario, +) -> None: + try: + scenario(smoke_config, provider_model) + except Exception as exc: + skip_if_upstream_unavailable_exception(exc) + raise AssertionError( + f"{provider_model.source}={provider_model.full_model}: " + f"{type(exc).__name__}: {exc}" + ) from exc + + +def _assert_provider_product_stream(events: list[SSEEvent]) -> None: + skip_if_upstream_unavailable_events(events) + assert_product_stream(events) + + +def _tool_use_blocks_or_skip( + events: list[SSEEvent], message: str +) -> list[dict[str, Any]]: + skip_if_upstream_unavailable_events(events) + blocks = tool_use_blocks(events) + assert blocks, message + return blocks + + +def _provider_smoke_thinking_enabled(smoke_config: SmokeConfig) -> bool: + return ( + ModelRouter(smoke_config.settings) + .resolve("claude-sonnet-4-5-20250929") + .thinking_enabled + ) + + +def _scenario_text_multiturn( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + with _server_for_provider(smoke_config, provider_model, "text") as server: + driver = ConversationDriver(server, smoke_config) + first = driver.ask("Reply with one short sentence.") + second = driver.ask("Reply with a different short sentence.") + _assert_provider_product_stream(first.events) + _assert_provider_product_stream(second.events) + + +def _scenario_adaptive_thinking_history( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + payload = { + "model": "claude-opus-4-7", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "unsigned hidden thought"}, + {"type": "redacted_thinking", "data": "opaque"}, + {"type": "text", "text": "Hello."}, + ], + }, + {"role": "user", "content": "Reply with one short sentence."}, + ], + "thinking": {"type": "adaptive", "budget_tokens": 1024}, + } + with _server_for_provider(smoke_config, provider_model, "adaptive") as server: + turn = ConversationDriver(server, smoke_config).stream(payload) + _assert_provider_product_stream(turn.events) + + +def _scenario_interleaved_history( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + payload = { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "Use the tool."}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Need to inspect first."}, + {"type": "text", "text": "I will call the tool."}, + { + "type": "tool_use", + "id": "toolu_interleaved", + "name": "echo_smoke", + "input": {"value": "FCC_INTERLEAVED"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_interleaved", + "content": "FCC_INTERLEAVED", + } + ], + }, + ], + "tools": [echo_tool_schema()], + "thinking": {"type": "adaptive"}, + } + with _server_for_provider(smoke_config, provider_model, "interleaved") as server: + turn = ConversationDriver(server, smoke_config).stream(payload) + _assert_provider_product_stream(turn.events) + + +def _scenario_tool_use_then_text_in_history( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + tool_id = "toolu_206_smoke" + payload = { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "We will use echo_smoke once in this session."}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": tool_id, + "name": "echo_smoke", + "input": {"value": "FCC_206_SMOKE"}, + }, + { + "type": "text", + "text": "Narration after the tool call (issue #206 shape).", + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_id, + "content": "FCC_206_SMOKE", + }, + ], + }, + { + "role": "user", + "content": "Reply in one short sentence: did you see the echo value?", + }, + ], + "tools": [echo_tool_schema()], + } + with _server_for_provider(smoke_config, provider_model, "tool-206") as server: + turn = ConversationDriver(server, smoke_config).stream(payload) + _assert_provider_product_stream(turn.events) + + +def _scenario_tool_result_continuation( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + first_payload = { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "Use echo_smoke once with value FCC_TOOL."} + ], + "tools": [echo_tool_schema()], + "tool_choice": {"type": "tool", "name": "echo_smoke"}, + "thinking": {"type": "adaptive"}, + } + with _server_for_provider(smoke_config, provider_model, "tool") as server: + driver = ConversationDriver(server, smoke_config) + first = driver.stream(first_payload) + tool_uses = _tool_use_blocks_or_skip( + first.events, "provider did not emit a tool_use block" + ) + tool_use = tool_uses[0] + second_payload = { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 256, + "messages": [ + first_payload["messages"][0], + {"role": "assistant", "content": first.assistant_content}, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_use["id"], + "content": "FCC_TOOL", + } + ], + }, + ], + "tools": [echo_tool_schema()], + } + second = driver.stream(second_payload) + _assert_provider_product_stream(first.events) + _assert_provider_product_stream(second.events) + + +def _scenario_gemini_thought_signature_tool_continuation( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + first_payload = { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "Use echo_smoke once with value FCC_TOOL."} + ], + "tools": [echo_tool_schema()], + "tool_choice": {"type": "tool", "name": "echo_smoke"}, + "thinking": {"type": "adaptive", "budget_tokens": 1024}, + } + with _server_for_provider( + smoke_config, provider_model, "gemini-signature" + ) as server: + driver = ConversationDriver(server, smoke_config) + first = driver.stream(first_payload) + tool_uses = _tool_use_blocks_or_skip( + first.events, "gemini did not emit a tool_use block" + ) + tool_use = tool_uses[0] + signature = _gemini_tool_thought_signature(tool_use) + assert signature, ( + "gemini tool_use did not preserve extra_content.google.thought_signature" + ) + second_payload = { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 256, + "messages": [ + first_payload["messages"][0], + {"role": "assistant", "content": first.assistant_content}, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": tool_use["id"], + "content": "FCC_TOOL", + } + ], + }, + ], + "tools": [echo_tool_schema()], + "thinking": {"type": "adaptive", "budget_tokens": 1024}, + } + second = driver.stream(second_payload) + _assert_provider_product_stream(first.events) + _assert_provider_product_stream(second.events) + + +def _gemini_tool_thought_signature(tool_use: dict[str, Any]) -> str | None: + extra_content = tool_use.get("extra_content") + if not isinstance(extra_content, dict): + return None + google = extra_content.get("google") + if not isinstance(google, dict): + return None + signature = google.get("thought_signature") + return signature if isinstance(signature, str) and signature else None + + +def _scenario_reasoning_tool_continuation( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + payload = { + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": "Use echo_smoke once with value FCC_TOOL."}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Need to return the echo result."}, + { + "type": "tool_use", + "id": "toolu_reasoning_smoke", + "name": "echo_smoke", + "input": {"value": "FCC_TOOL"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_reasoning_smoke", + "content": "FCC_TOOL", + } + ], + }, + ], + "tools": [echo_tool_schema()], + "thinking": {"type": "adaptive"}, + } + with _server_for_provider(smoke_config, provider_model, "reasoning-tool") as server: + turn = ConversationDriver(server, smoke_config).stream(payload) + _assert_provider_product_stream(turn.events) + + +def _scenario_disconnect( + smoke_config: SmokeConfig, provider_model: ProviderModel +) -> None: + with _server_for_provider(smoke_config, provider_model, "disconnect") as server: + with httpx.stream( + "POST", + f"{server.base_url}/v1/messages", + headers=auth_headers(), + json={ + "model": "fcc-smoke-default", + "max_tokens": 512, + "messages": [{"role": "user", "content": smoke_config.prompt}], + }, + timeout=smoke_config.timeout_s, + ) as response: + assert response.status_code == 200, response.read() + for _line in response.iter_lines(): + break + health = httpx.get(f"{server.base_url}/health", timeout=5) + assert health.status_code == 200 + followup = ConversationDriver(server, smoke_config).ask( + "Reply with one short sentence." + ) + _assert_provider_product_stream(followup.events) + + +def _server_for_provider( + smoke_config: SmokeConfig, provider_model: ProviderModel, name: str +): + return SmokeServerDriver( + smoke_config, + name=f"product-provider-{provider_model.provider}-{name}", + env_overrides={ + "MODEL": provider_model.full_model, + "MESSAGING_PLATFORM": "none", + }, + ).run() + + +def _openai_auth_headers(smoke_config: SmokeConfig) -> dict[str, str]: + headers = {"content-type": "application/json"} + token = smoke_config.settings.anthropic_auth_token + if token: + headers["authorization"] = f"Bearer {token}" + return headers diff --git a/smoke/product/test_runtime_ownership_product_live.py b/smoke/product/test_runtime_ownership_product_live.py new file mode 100644 index 0000000..3652770 --- /dev/null +++ b/smoke/product/test_runtime_ownership_product_live.py @@ -0,0 +1,311 @@ +"""Credential-free subprocess coverage for provider generation replacement.""" + +import json +import threading +import time +from contextlib import ExitStack +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from free_claude_code.config.env_template import load_env_template +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from smoke.lib.config import SmokeConfig +from smoke.lib.server import RunningServer, start_server + +pytestmark = [pytest.mark.live, pytest.mark.smoke_target("api")] + + +class FakeOpenAIUpstream: + """Minimal OpenAI-chat upstream with an optionally held response stream.""" + + def __init__(self, label: str, *, hold_stream: bool) -> None: + self.label = label + self.hold_stream = hold_stream + self.chat_started = threading.Event() + self.release_stream = threading.Event() + self.chat_requests: list[dict[str, Any]] = [] + self._server: ThreadingHTTPServer | None = None + self._thread: threading.Thread | None = None + + @property + def base_url(self) -> str: + if self._server is None: + raise RuntimeError("Fake upstream is not running") + port = int(self._server.server_address[1]) + return f"http://127.0.0.1:{port}/v1" + + def __enter__(self) -> FakeOpenAIUpstream: + upstream = self + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, format: str, *args: Any) -> None: + return + + def do_GET(self) -> None: + if self.path.rstrip("/").endswith("/models"): + upstream._send_models(self) + return + self.send_error(404) + + def do_POST(self) -> None: + if self.path.rstrip("/").endswith("/chat/completions"): + upstream._send_chat(self) + return + self.send_error(404) + + self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self._server.daemon_threads = True + self._thread = threading.Thread( + target=self._server.serve_forever, + name=f"fake-openai-{self.label}", + daemon=True, + ) + self._thread.start() + return self + + def __exit__(self, *_exc: object) -> None: + self.release_stream.set() + if self._server is not None: + self._server.shutdown() + self._server.server_close() + if self._thread is not None: + self._thread.join(timeout=5) + + def _send_models(self, handler: BaseHTTPRequestHandler) -> None: + payload = json.dumps( + { + "object": "list", + "data": [ + { + "id": f"model-{self.label}", + "object": "model", + "created": 0, + "owned_by": "smoke", + } + ], + } + ).encode() + handler.send_response(200) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(payload))) + handler.end_headers() + handler.wfile.write(payload) + + def _send_chat(self, handler: BaseHTTPRequestHandler) -> None: + length = int(handler.headers.get("content-length", "0")) + body = handler.rfile.read(length) + request = json.loads(body) if body else {} + self.chat_requests.append(request) + handler.send_response(200) + handler.send_header("Content-Type", "text/event-stream") + handler.send_header("Cache-Control", "no-cache") + handler.send_header("Connection", "close") + handler.end_headers() + try: + self._write_chunk( + handler, + content=f"provider-{self.label}-start ", + finish_reason=None, + ) + if self.hold_stream: + time.sleep(0.85) + self._write_chunk( + handler, + content=f"provider-{self.label}-held ", + finish_reason=None, + ) + self.chat_started.set() + if self.hold_stream and not self.release_stream.wait(timeout=30): + return + self._write_chunk( + handler, + content=f"provider-{self.label}-finish", + finish_reason=None, + ) + self._write_chunk(handler, content=None, finish_reason="stop") + handler.wfile.write(b"data: [DONE]\n\n") + handler.wfile.flush() + except OSError: + return + + def _write_chunk( + self, + handler: BaseHTTPRequestHandler, + *, + content: str | None, + finish_reason: str | None, + ) -> None: + delta: dict[str, str] = {} + if content is not None: + delta = {"role": "assistant", "content": content} + payload = { + "id": f"chatcmpl-{self.label}", + "object": "chat.completion.chunk", + "created": 0, + "model": f"model-{self.label}", + "choices": [ + { + "index": 0, + "delta": delta, + "finish_reason": finish_reason, + } + ], + } + handler.wfile.write(f"data: {json.dumps(payload)}\n\n".encode()) + handler.wfile.flush() + + +def _message_payload(*, stream: bool) -> dict[str, Any]: + return { + "model": "claude-sonnet-4-20250514", + "max_tokens": 64, + "messages": [{"role": "user", "content": "generation smoke"}], + "stream": stream, + } + + +def _write_initial_managed_config(home: Path, upstream: FakeOpenAIUpstream) -> None: + config_path = home / ".fcc" / ".env" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + load_env_template() + + "\n" + + "\n".join( + [ + "MODEL=lmstudio/model-a", + "MODEL_OPUS=", + "MODEL_SONNET=", + "MODEL_HAIKU=", + f"LM_STUDIO_BASE_URL={upstream.base_url}", + "ANTHROPIC_AUTH_TOKEN=", + "ENABLE_MODEL_THINKING=false", + "MESSAGING_PLATFORM=none", + "", + ] + ), + encoding="utf-8", + ) + + +def _wait_for_generation_close(server: RunningServer, generation_id: int) -> None: + deadline = time.monotonic() + 10 + generation_field = f'"generation_id": {generation_id}' + while time.monotonic() < deadline: + text = server.log_path.read_text(encoding="utf-8", errors="replace") + if "provider_generation.closed" in text and generation_field in text: + return + time.sleep(0.1) + raise AssertionError( + f"generation {generation_id} close trace was not written:\n" + + server.log_path.read_text(encoding="utf-8", errors="replace")[-3000:] + ) + + +def test_provider_hot_swap_preserves_inflight_stream_e2e( + smoke_config: SmokeConfig, + tmp_path: Path, +) -> None: + home = tmp_path / "home" + downstream_started = threading.Event() + old_body: list[str] = [] + old_errors: list[BaseException] = [] + credential_env_keys = { + descriptor.credential_env + for descriptor in PROVIDER_CATALOG.values() + if descriptor.credential_env is not None + } + + with ExitStack() as stack: + upstream_a = stack.enter_context(FakeOpenAIUpstream("a", hold_stream=True)) + upstream_b = stack.enter_context(FakeOpenAIUpstream("b", hold_stream=False)) + _write_initial_managed_config(home, upstream_a) + server = stack.enter_context( + start_server( + smoke_config, + name="runtime-ownership", + env_overrides={ + "HOME": str(home), + "USERPROFILE": str(home), + }, + env_unset=credential_env_keys + | { + "ANTHROPIC_AUTH_TOKEN", + "ENABLE_MODEL_THINKING", + "FCC_ENV_FILE", + "LM_STUDIO_BASE_URL", + "MODEL", + "MODEL_HAIKU", + "MODEL_OPUS", + "MODEL_SONNET", + }, + ) + ) + + def consume_old_stream() -> None: + try: + with httpx.stream( + "POST", + f"{server.base_url}/v1/messages", + json=_message_payload(stream=True), + timeout=30, + ) as response: + response.raise_for_status() + downstream_started.set() + old_body.append("".join(response.iter_text())) + except BaseException as exc: + old_errors.append(exc) + downstream_started.set() + + old_thread = threading.Thread( + target=consume_old_stream, + name="old-generation-consumer", + ) + old_thread.start() + assert downstream_started.wait(timeout=10) + assert upstream_a.chat_started.is_set() + assert old_errors == [] + + apply_response = httpx.post( + f"{server.base_url}/admin/api/config/apply", + json={ + "values": { + "MODEL": "lmstudio/model-b", + "LM_STUDIO_BASE_URL": upstream_b.base_url, + } + }, + timeout=smoke_config.timeout_s, + ) + assert apply_response.status_code == 200, apply_response.text + apply_body = apply_response.json() + assert apply_body["applied"] is True + assert apply_body["pending_fields"] == [] + assert apply_body["restart"]["required"] is False + + new_response = httpx.post( + f"{server.base_url}/v1/messages", + json=_message_payload(stream=False), + timeout=smoke_config.timeout_s, + ) + assert new_response.status_code == 200, new_response.text + new_text = "".join( + block.get("text", "") for block in new_response.json()["content"] + ) + assert new_text == "provider-b-start provider-b-finish" + assert not old_body + + upstream_a.release_stream.set() + old_thread.join(timeout=10) + assert not old_thread.is_alive() + assert old_errors == [] + assert "provider-a-start " in old_body[0] + assert "provider-a-held " in old_body[0] + assert "provider-a-finish" in old_body[0] + assert len(upstream_a.chat_requests) == 1 + assert len(upstream_b.chat_requests) == 1 + _wait_for_generation_close(server, generation_id=1) diff --git a/smoke/product/test_voice_product_live.py b/smoke/product/test_voice_product_live.py new file mode 100644 index 0000000..23947d2 --- /dev/null +++ b/smoke/product/test_voice_product_live.py @@ -0,0 +1,68 @@ +import os +from pathlib import Path + +import pytest + +from free_claude_code.messaging.transcription import TranscriptionService +from free_claude_code.providers.nvidia_nim.voice import NvidiaNimTranscriber +from smoke.lib.config import SmokeConfig +from smoke.lib.e2e import VoiceFixtureDriver + +pytestmark = [pytest.mark.live] + + +@pytest.mark.smoke_target("voice") +@pytest.mark.asyncio +async def test_voice_local_backend_e2e( + smoke_config: SmokeConfig, tmp_path: Path +) -> None: + if not smoke_config.settings.voice_note_enabled: + pytest.skip("missing_env: VOICE_NOTE_ENABLED is false") + if os.getenv("FCC_SMOKE_RUN_VOICE") != "1": + pytest.skip("missing_env: set FCC_SMOKE_RUN_VOICE=1 to run voice product smoke") + if smoke_config.settings.whisper_device not in {"cpu", "cuda"}: + pytest.skip("missing_env: WHISPER_DEVICE must be cpu or cuda") + + wav_path = tmp_path / "voice-local-product.wav" + VoiceFixtureDriver.write_tone_wav(wav_path) + transcriber = TranscriptionService( + model=smoke_config.settings.whisper_model, + device=smoke_config.settings.whisper_device, + huggingface_api_key=smoke_config.settings.huggingface_api_key, + ) + try: + text = await transcriber.transcribe(wav_path) + except ImportError as exc: + pytest.skip(f"missing_env: {exc}") + finally: + await transcriber.close() + + assert isinstance(text, str) + assert text.strip() + + +@pytest.mark.smoke_target("voice") +@pytest.mark.asyncio +async def test_voice_nim_backend_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None: + if not smoke_config.settings.voice_note_enabled: + pytest.skip("missing_env: VOICE_NOTE_ENABLED is false") + if os.getenv("FCC_SMOKE_RUN_VOICE") != "1": + pytest.skip("missing_env: set FCC_SMOKE_RUN_VOICE=1 to run voice product smoke") + if smoke_config.settings.whisper_device != "nvidia_nim": + pytest.skip("missing_env: WHISPER_DEVICE must be nvidia_nim") + if not smoke_config.settings.nvidia_nim_api_key.strip(): + pytest.skip("missing_env: NVIDIA_NIM_API_KEY is required") + + wav_path = tmp_path / "voice-nim-product.wav" + VoiceFixtureDriver.write_tone_wav(wav_path) + transcriber = NvidiaNimTranscriber( + model=smoke_config.settings.whisper_model, + api_key=smoke_config.settings.nvidia_nim_api_key, + ) + try: + text = await transcriber.transcribe(wav_path) + finally: + await transcriber.close() + + assert isinstance(text, str) + assert text.strip() diff --git a/src/free_claude_code/__init__.py b/src/free_claude_code/__init__.py new file mode 100644 index 0000000..9710af1 --- /dev/null +++ b/src/free_claude_code/__init__.py @@ -0,0 +1 @@ +"""Free Claude Code package.""" diff --git a/src/free_claude_code/api/__init__.py b/src/free_claude_code/api/__init__.py new file mode 100644 index 0000000..df901bf --- /dev/null +++ b/src/free_claude_code/api/__init__.py @@ -0,0 +1 @@ +"""HTTP API adapter for Free Claude Code.""" diff --git a/src/free_claude_code/api/admin_routes.py b/src/free_claude_code/api/admin_routes.py new file mode 100644 index 0000000..7d78582 --- /dev/null +++ b/src/free_claude_code/api/admin_routes.py @@ -0,0 +1,200 @@ +"""Local admin UI routes and APIs.""" + +import ipaddress +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +import httpx +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field + +from free_claude_code.config.admin.manifest import FIELD_BY_KEY +from free_claude_code.config.admin.persistence import validate_updates +from free_claude_code.config.admin.values import load_config_response + +from .dependencies import get_services +from .ports import ApiServices + +router = APIRouter() + +STATIC_DIR = Path(__file__).resolve().parent / "admin_static" +LOCAL_PROVIDER_PATHS = { + "lmstudio": "/models", + "llamacpp": "/models", + "ollama": "/api/tags", +} + + +class AdminConfigPayload(BaseModel): + """Partial config update submitted by the admin UI.""" + + values: dict[str, Any] = Field(default_factory=dict) + + +def _is_loopback_host(host: str | None) -> bool: + if host is None: + return False + normalized = host.strip().strip("[]").lower() + if normalized == "localhost": + return True + try: + return ipaddress.ip_address(normalized).is_loopback + except ValueError: + return False + + +def _origin_is_local(origin: str | None) -> bool: + if not origin: + return True + parsed = urlsplit(origin) + return _is_loopback_host(parsed.hostname) + + +def require_loopback_admin(request: Request) -> None: + """Allow admin access only from the local machine.""" + + client_host = request.client.host if request.client else None + if not _is_loopback_host(client_host): + raise HTTPException(status_code=403, detail="Admin UI is local-only") + + origin = request.headers.get("origin") + if not _origin_is_local(origin): + raise HTTPException(status_code=403, detail="Admin UI is local-only") + + +def _asset_response(filename: str) -> FileResponse: + path = STATIC_DIR / filename + if not path.is_file(): + raise HTTPException(status_code=404, detail="Admin asset not found") + return FileResponse(path) + + +@router.get("/admin", include_in_schema=False) +async def admin_page(request: Request): + require_loopback_admin(request) + return _asset_response("index.html") + + +@router.get("/admin/assets/{filename}", include_in_schema=False) +async def admin_asset(filename: str, request: Request): + require_loopback_admin(request) + if filename not in {"admin.css", "admin.js"}: + raise HTTPException(status_code=404, detail="Admin asset not found") + return _asset_response(filename) + + +@router.get("/admin/api/config") +async def get_admin_config(request: Request): + require_loopback_admin(request) + return load_config_response() + + +@router.post("/admin/api/config/validate") +async def validate_admin_config(payload: AdminConfigPayload, request: Request): + require_loopback_admin(request) + return validate_updates(_filtered_values(payload.values)) + + +@router.post("/admin/api/config/apply") +async def apply_admin_config( + payload: AdminConfigPayload, + request: Request, + background_tasks: BackgroundTasks, + services: ApiServices = Depends(get_services), +): + require_loopback_admin(request) + result = await services.admin.apply_admin_config(_filtered_values(payload.values)) + restart = result.get("restart") + if isinstance(restart, dict) and restart.get("automatic"): + background_tasks.add_task(services.admin.request_restart) + return result + + +@router.get("/admin/api/status") +async def admin_status( + request: Request, + services: ApiServices = Depends(get_services), +): + require_loopback_admin(request) + return services.admin.admin_status() + + +@router.get("/admin/api/providers/local-status") +async def local_provider_status(request: Request): + require_loopback_admin(request) + config = load_config_response() + values = {field["key"]: field["value"] for field in config["fields"]} + checks = [] + for provider_id, path in LOCAL_PROVIDER_PATHS.items(): + base_url = _local_provider_url(provider_id, values) + checks.append(await _check_local_provider(provider_id, base_url, path)) + return {"providers": checks} + + +@router.post("/admin/api/providers/{provider_id}/test") +async def test_provider( + provider_id: str, + request: Request, + services: ApiServices = Depends(get_services), +): + require_loopback_admin(request) + return await services.admin.test_provider(provider_id) + + +@router.post("/admin/api/models/refresh") +async def refresh_models( + request: Request, + services: ApiServices = Depends(get_services), +): + require_loopback_admin(request) + return await services.admin.refresh_models() + + +def _filtered_values(values: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in values.items() if key in FIELD_BY_KEY} + + +def _local_provider_url(provider_id: str, values: dict[str, str]) -> str: + if provider_id == "lmstudio": + return values.get("LM_STUDIO_BASE_URL", "") + if provider_id == "llamacpp": + return values.get("LLAMACPP_BASE_URL", "") + if provider_id == "ollama": + return values.get("OLLAMA_BASE_URL", "") + return "" + + +async def _check_local_provider( + provider_id: str, base_url: str, path: str +) -> dict[str, Any]: + clean_url = base_url.strip().rstrip("/") + if not clean_url: + return { + "provider_id": provider_id, + "status": "missing_url", + "label": "Missing URL", + "base_url": base_url, + } + + url = f"{clean_url}{path}" + try: + async with httpx.AsyncClient(timeout=1.5) as client: + response = await client.get(url) + ok = 200 <= response.status_code < 300 + return { + "provider_id": provider_id, + "status": "reachable" if ok else "offline", + "label": "Reachable" if ok else "Offline", + "base_url": base_url, + "status_code": response.status_code, + } + except Exception as exc: + return { + "provider_id": provider_id, + "status": "offline", + "label": "Offline", + "base_url": base_url, + "error_type": type(exc).__name__, + } diff --git a/src/free_claude_code/api/admin_static/admin.css b/src/free_claude_code/api/admin_static/admin.css new file mode 100644 index 0000000..659902b --- /dev/null +++ b/src/free_claude_code/api/admin_static/admin.css @@ -0,0 +1,736 @@ +:root { + color-scheme: dark; + + /* Color Palette */ + --bg: #090a0f; + --panel: #11131c; + --panel-strong: #171b26; + --card: #151822; + --card-hover: #1e2230; + --input: #0a0b10; + --input-focus: #0f1118; + --text: #f3f4f6; + --text-strong: #ffffff; + --muted: #9ca3af; + --line: rgba(255, 255, 255, 0.06); + --line-strong: rgba(255, 255, 255, 0.12); + + /* Brand and Accent Colors */ + --accent: #10b981; + --accent-dark: #059669; + --accent-muted: rgba(16, 185, 129, 0.08); + --accent-border: rgba(16, 185, 129, 0.3); + + /* Status Colors */ + --ok: #10b981; + --ok-bg: rgba(16, 185, 129, 0.08); + --ok-border: rgba(16, 185, 129, 0.25); + + --warn: #f59e0b; + --warn-bg: rgba(245, 158, 11, 0.08); + --warn-border: rgba(245, 158, 11, 0.25); + + --error: #ef4444; + --error-bg: rgba(239, 68, 68, 0.08); + --error-border: rgba(239, 68, 68, 0.25); + + --neutral: #6b7280; + --neutral-bg: rgba(107, 114, 128, 0.08); + --neutral-border: rgba(107, 114, 128, 0.25); + + --info: #3b82f6; + + /* Box Shadow & Glows */ + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2); + --shadow: 0 12px 34px rgba(0, 0, 0, 0.5); + --glow-accent: 0 0 12px rgba(16, 185, 129, 0.15); + + /* Borders and Radius */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-full: 9999px; + + /* Typography */ + --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + + /* Transitions */ + --transition-fast: 0.15s ease; + --transition-normal: 0.22s cubic-bezier(0.4, 0, 0.2, 1); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + letter-spacing: -0.01em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Custom Scrollbars */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: var(--bg); +} +::-webkit-scrollbar-thumb { + background: var(--panel-strong); + border-radius: var(--radius-full); +} +::-webkit-scrollbar-thumb:hover { + background: var(--muted); +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +/* App Shell Layout */ +.app-shell { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + min-height: 100vh; + padding-bottom: 96px; /* Space for action bar */ +} + +/* Sidebar Navigation */ +.sidebar { + position: sticky; + top: 0; + height: 100vh; + border-right: 1px solid var(--line); + background: var(--bg); + padding: 24px 20px; + display: flex; + flex-direction: column; + gap: 32px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + display: grid; + width: 40px; + height: 40px; + place-items: center; + border-radius: var(--radius-md); + background: linear-gradient(135deg, var(--accent), var(--accent-dark)); + color: #ffffff; + font-weight: 800; + font-size: 15px; + box-shadow: var(--glow-accent); +} + +.brand h1 { + font-size: 16px; + font-weight: 700; + line-height: 1.2; + color: var(--text-strong); + margin: 0; +} + +.brand p { + color: var(--muted); + font-size: 12px; + margin: 0; +} + +.section-nav { + display: grid; + gap: 6px; +} + +.nav-link { + display: flex; + align-items: center; + width: 100%; + min-height: 40px; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: transparent; + color: var(--muted); + padding: 10px 14px; + text-align: left; + cursor: pointer; + font-weight: 600; + font-size: 14px; + transition: all var(--transition-fast); +} + +.nav-link:hover { + background: var(--panel-strong); + color: var(--text-strong); +} + +.nav-link.active { + background: var(--accent-muted); + border-color: var(--accent-border); + color: var(--accent); + box-shadow: var(--shadow-sm); +} + +/* Accessible focus outlines */ +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +.nav-link:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Main Content Area */ +.main { + min-width: 0; + padding: 40px 48px; +} + +.topbar { + margin-bottom: 32px; +} + +.topbar h2 { + font-size: 30px; + font-weight: 800; + letter-spacing: -0.02em; + color: var(--text-strong); + margin: 0; +} + +/* Provider Strip & Panels */ +.provider-strip, +.settings-section { + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--panel); + box-shadow: var(--shadow); + margin-bottom: 24px; + transition: border-color var(--transition-normal); +} + +.provider-strip { + padding: 24px; +} + +.strip-header, +.section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} + +.strip-header h3, +.section-heading h3 { + font-size: 18px; + font-weight: 700; + color: var(--text-strong); + margin: 0; +} + +.section-heading p { + color: var(--muted); + font-size: 13px; + line-height: 1.4; + margin: 4px 0 0 0; +} + +/* Provider Status Cards */ +.provider-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 16px; +} + +.provider-card { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 16px; + min-height: 140px; + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: 16px; + background: var(--card); + transition: all var(--transition-normal); +} + +.provider-card:hover { + border-color: var(--line-strong); + transform: translateY(-2px); + box-shadow: var(--shadow-sm); + background: var(--card-hover); +} + +.provider-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.provider-title strong { + font-size: 14px; + font-weight: 700; + color: var(--text-strong); +} + +/* Modern status pills with color indicators */ +.status-pill { + display: inline-flex; + align-items: center; + min-height: 22px; + border: 1px solid var(--neutral-border); + border-radius: var(--radius-full); + padding: 2px 10px; + background: var(--neutral-bg); + color: var(--muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; +} + +.status-pill.ok { + color: var(--ok); + background: var(--ok-bg); + border-color: var(--ok-border); +} + +.status-pill.warn { + color: var(--warn); + background: var(--warn-bg); + border-color: var(--warn-border); +} + +.status-pill.error { + color: var(--error); + background: var(--error-bg); + border-color: var(--error-border); +} + +.provider-meta { + color: var(--muted); + font-size: 12px; + line-height: 1.4; + word-break: break-all; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +/* Button & Form Controls styling */ +.test-button, +.ghost-button, +.secondary-button, +.primary-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + border-radius: var(--radius-md); + border: 1px solid var(--line-strong); + padding: 6px 14px; + font-weight: 600; + font-size: 13px; + cursor: pointer; + transition: all var(--transition-fast); + text-decoration: none; +} + +.test-button { + background: var(--panel-strong); + color: var(--text); + border-color: var(--line); + margin-top: auto; + align-self: flex-start; +} + +.test-button:hover:not(:disabled) { + background: var(--card-hover); + border-color: var(--accent); + color: var(--accent); +} + +.test-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.secondary-button { + background: transparent; + color: var(--text); + border-color: var(--line-strong); +} + +.secondary-button:hover:not(:disabled) { + background: var(--panel-strong); + border-color: var(--text); +} + +.primary-button { + border-color: var(--accent); + background: var(--accent); + color: #06100b; +} + +.primary-button:hover:not(:disabled) { + background: var(--accent-dark); +} + +.primary-button:disabled { + cursor: not-allowed; + border-color: var(--line); + background: var(--panel-strong); + color: var(--muted); +} + +.ghost-button { + background: transparent; + color: var(--muted); + border-color: transparent; +} + +.ghost-button:hover:not(:disabled) { + color: var(--text-strong); + background: var(--panel-strong); +} + +/* Forms layout */ +.form-sections { + display: grid; + gap: 24px; +} + +.settings-section { + padding: 24px; + scroll-margin-top: 24px; +} + +.section-heading { + border-bottom: 1px solid var(--line); + padding-bottom: 16px; + margin-bottom: 24px; +} + +.field-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; +} + +/* Field Grouping */ +.field { + display: flex; + flex-direction: column; + gap: 8px; + align-content: start; +} + +.field label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 13px; + font-weight: 700; + color: var(--text-strong); +} + +.field-source { + color: var(--accent); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + background: var(--accent-muted); + padding: 1px 6px; + border-radius: var(--radius-sm); +} + +/* Form Inputs, Select, Textareas */ +.field input[type="text"], +.field input[type="number"], +.field input[type="password"], +.field select, +.field textarea { + width: 100%; + min-height: 40px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-md); + background: var(--input); + color: var(--text-strong); + padding: 10px 14px; + font-size: 14px; + transition: all var(--transition-fast); +} + +/* Custom Dropdown Styling for Select Elements */ +.field select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%239ca3af' stroke-width='2.5'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M19.5 8.25l-7.5 7.5-7.5-7.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + background-size: 14px; + padding-right: 36px; +} + +/* Custom Dropdown Styling for Datalist Input Elements (Model Routing) */ +.field input[list] { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%239ca3af' stroke-width='2.5'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M19.5 8.25l-7.5 7.5-7.5-7.5'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + background-size: 14px; + padding-right: 36px; +} + +/* Hide WebKit's native picker indicator to avoid double arrow icons */ +.field input[list]::-webkit-calendar-picker-indicator { + display: none !important; +} + + +.field input:focus, +.field select:focus, +.field textarea:focus { + border-color: var(--accent); + background: var(--input-focus); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15); + outline: none; +} + +/* Checkbox specific layout styling */ +.field input[type="checkbox"] { + align-self: flex-start; + width: 20px; + height: 20px; + accent-color: var(--accent); + cursor: pointer; + margin: 4px 0; +} + +.field textarea { + min-height: 100px; + resize: vertical; + line-height: 1.5; +} + +.field input:disabled, +.field select:disabled, +.field textarea:disabled { + background: rgba(255, 255, 255, 0.02); + border-color: var(--line); + color: var(--muted); + cursor: not-allowed; +} + +.field-description { + color: var(--muted); + font-size: 12px; + line-height: 1.4; + margin-top: 2px; +} + +/* Show/Hide Advanced fields */ +.field.advanced-field { + display: none; +} + +.settings-section.show-advanced .advanced-field { + display: flex; +} + +.advanced-toggle { + margin-top: 20px; + font-size: 12px; + border: 1px solid var(--line-strong); +} + +/* Fixed Bottom Action Bar */ +.action-bar { + position: fixed; + right: 0; + bottom: 0; + left: 260px; + z-index: 100; + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(200px, 1fr) auto; + gap: 20px; + align-items: center; + min-height: 80px; + border-top: 1px solid var(--line); + background: rgba(9, 10, 15, 0.82); + padding: 16px 40px; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.3); +} + +.action-meta { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.action-meta strong { + font-size: 14px; + font-weight: 700; + color: var(--text-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.action-meta span { + color: var(--muted); + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.message-area { + min-width: 0; + color: var(--muted); + font-size: 13px; + font-weight: 500; + line-height: 1.4; +} + +.message-area.error { + color: var(--error); + font-weight: 600; +} + +.message-area.ok { + color: var(--ok); + font-weight: 600; +} + +.action-buttons { + display: flex; + gap: 12px; +} + +.action-buttons button { + min-width: 100px; +} + +/* Tablet Layout Optimization */ +@media (max-width: 900px) { + .app-shell { + display: flex; + flex-direction: column; + padding-bottom: 180px; /* More room for stacked action bar */ + } + + .sidebar { + position: relative; + height: auto; + border-right: 0; + border-bottom: 1px solid var(--line); + padding: 20px; + gap: 20px; + } + + .section-nav { + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + } + + .main { + padding: 24px 20px; + } + + .action-bar { + left: 0; + grid-template-columns: 1fr; + gap: 12px; + padding: 16px 20px; + min-height: auto; + background: rgba(9, 10, 15, 0.95); + } + + .action-meta { + flex-direction: row; + justify-content: space-between; + align-items: center; + } + + .action-meta span { + text-align: right; + } + + .action-buttons { + width: 100%; + } + + .action-buttons button { + flex: 1; + min-height: 44px; /* Better touch target on mobile */ + } +} + +/* Mobile Layout Optimization */ +@media (max-width: 600px) { + .brand { + justify-content: center; + text-align: center; + flex-direction: column; + } + + .brand-mark { + width: 48px; + height: 48px; + } + + .section-nav { + grid-template-columns: 1fr; + } + + .topbar { + text-align: center; + } + + .topbar h2 { + font-size: 24px; + } + + .provider-grid { + grid-template-columns: 1fr; + } + + .field-grid { + grid-template-columns: 1fr; + } + + .action-meta { + flex-direction: column; + align-items: stretch; + gap: 2px; + } + + .action-meta span { + text-align: left; + } +} diff --git a/src/free_claude_code/api/admin_static/admin.js b/src/free_claude_code/api/admin_static/admin.js new file mode 100644 index 0000000..3de3f0f --- /dev/null +++ b/src/free_claude_code/api/admin_static/admin.js @@ -0,0 +1,475 @@ +const state = { + config: null, + fields: new Map(), + localStatus: new Map(), + modelOptions: [], + activeView: "providers", +}; + +const MASKED_SECRET = "********"; +const VIEW_GROUPS = [ + { + id: "providers", + label: "Providers", + title: "Providers", + sections: ["providers", "runtime"], + containerId: "providersSections", + }, + { + id: "model_config", + label: "Model Config", + title: "Model Config", + sections: ["models", "thinking", "web_tools"], + containerId: "modelConfigSections", + }, + { + id: "messaging", + label: "Messaging", + title: "Messaging", + sections: ["messaging", "voice"], + containerId: "messagingSections", + }, +]; + +const byId = (id) => document.getElementById(id); + +function sourceLabel(source) { + const labels = { + default: "default", + template: "template", + repo_env: "repo .env", + managed_env: "", + explicit_env_file: "FCC_ENV_FILE", + process: "process env", + }; + return Object.prototype.hasOwnProperty.call(labels, source) ? labels[source] : source; +} + +function sourceText(field) { + const parts = []; + const label = sourceLabel(field.source); + if (label) { + parts.push(label); + } + if (field.locked) { + parts.push("locked"); + } + return parts.join(" "); +} + +function statusClass(status) { + if (["configured", "reachable", "running"].includes(status)) return "ok"; + if (["missing_key", "missing_url", "unknown"].includes(status)) return "warn"; + if (["offline", "error"].includes(status)) return "error"; + return "neutral"; +} + +async function api(path, options = {}) { + const response = await fetch(path, { + headers: { "Content-Type": "application/json", ...(options.headers || {}) }, + ...options, + }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + return response.json(); +} + +async function load() { + showMessage("Loading admin config"); + const config = await api("/admin/api/config"); + state.config = config; + state.fields = new Map(config.fields.map((field) => [field.key, field])); + renderNav(); + renderProviders(config.provider_status); + renderSections(config.sections, config.fields); + byId("configPath").textContent = config.paths.managed; + await validate(false); + await refreshLocalStatus(); + updateDirtyState(); + showMessage(""); +} + +function renderNav() { + const nav = byId("sectionNav"); + nav.innerHTML = ""; + VIEW_GROUPS.forEach((view, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = `nav-link${index === 0 ? " active" : ""}`; + button.dataset.view = view.id; + button.textContent = view.label; + if (index === 0) { + button.setAttribute("aria-current", "page"); + } + button.addEventListener("click", () => { + setActiveView(view.id, { scroll: true }); + }); + nav.appendChild(button); + }); + setActiveView(state.activeView, { scroll: false }); +} + +function setActiveView(viewId, { scroll = false } = {}) { + const activeView = + VIEW_GROUPS.find((view) => view.id === viewId) || VIEW_GROUPS[0]; + state.activeView = activeView.id; + byId("pageTitle").textContent = activeView.title; + + document.querySelectorAll(".nav-link").forEach((link) => { + const selected = link.dataset.view === activeView.id; + link.classList.toggle("active", selected); + if (selected) { + link.setAttribute("aria-current", "page"); + } else { + link.removeAttribute("aria-current"); + } + }); + + document.querySelectorAll(".admin-view").forEach((view) => { + const selected = view.dataset.view === activeView.id; + view.classList.toggle("active", selected); + view.hidden = !selected; + }); + + if (scroll) { + window.scrollTo({ top: 0, behavior: "smooth" }); + } +} + +function renderProviders(providerStatus) { + const grid = byId("providerGrid"); + grid.innerHTML = ""; + providerStatus.forEach((provider) => { + const card = document.createElement("article"); + card.className = "provider-card"; + card.dataset.provider = provider.provider_id; + + const title = document.createElement("div"); + title.className = "provider-title"; + title.innerHTML = `${provider.display_name || provider.provider_id}`; + + const pill = document.createElement("span"); + pill.className = `status-pill ${statusClass(provider.status)}`; + pill.textContent = provider.label; + title.appendChild(pill); + + const meta = document.createElement("div"); + meta.className = "provider-meta"; + meta.textContent = + provider.kind === "local" + ? provider.base_url || "No local URL configured" + : provider.credential_env; + + const button = document.createElement("button"); + button.type = "button"; + button.className = "test-button"; + button.textContent = provider.kind === "local" ? "Test" : "Refresh models"; + button.addEventListener("click", () => testProvider(provider.provider_id, button)); + + card.append(title, meta, button); + grid.appendChild(card); + }); +} + +function updateProviderCard(providerId, status, label, metaText) { + const card = document.querySelector(`[data-provider="${providerId}"]`); + if (!card) return; + const pill = card.querySelector(".status-pill"); + pill.className = `status-pill ${statusClass(status)}`; + pill.textContent = label; + if (metaText) { + card.querySelector(".provider-meta").textContent = metaText; + } +} + +function renderSections(sections, fields) { + VIEW_GROUPS.forEach((view) => { + byId(view.containerId).innerHTML = ""; + }); + + const sectionById = new Map(sections.map((section) => [section.id, section])); + const bySection = new Map(); + sections.forEach((section) => bySection.set(section.id, [])); + fields.forEach((field) => { + if (!bySection.has(field.section)) bySection.set(field.section, []); + bySection.get(field.section).push(field); + }); + + VIEW_GROUPS.forEach((view) => { + const container = byId(view.containerId); + view.sections.forEach((sectionId) => { + const section = sectionById.get(sectionId); + const sectionFields = bySection.get(sectionId) || []; + if (!section || sectionFields.length === 0) return; + + const sectionEl = document.createElement("section"); + sectionEl.className = "settings-section"; + sectionEl.id = `section-${section.id}`; + + const heading = document.createElement("div"); + heading.className = "section-heading"; + heading.innerHTML = `

`; + sectionEl.appendChild(heading); + + const grid = document.createElement("div"); + grid.className = "field-grid"; + sectionFields.forEach((field) => { + grid.appendChild(renderField(field)); + }); + sectionEl.appendChild(grid); + + if (sectionFields.some((field) => field.advanced)) { + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "ghost-button advanced-toggle"; + toggle.textContent = "Show advanced"; + toggle.addEventListener("click", () => { + const showing = sectionEl.classList.toggle("show-advanced"); + toggle.textContent = showing ? "Hide advanced" : "Show advanced"; + }); + sectionEl.appendChild(toggle); + } + + container.appendChild(sectionEl); + }); + }); +} + +function renderField(field) { + const wrapper = document.createElement("div"); + wrapper.className = `field${field.advanced ? " advanced-field" : ""}`; + wrapper.dataset.key = field.key; + + const label = document.createElement("label"); + label.htmlFor = `field-${field.key}`; + const labelText = document.createElement("span"); + labelText.textContent = field.label; + label.appendChild(labelText); + + const source = sourceText(field); + if (source) { + const sourceEl = document.createElement("span"); + sourceEl.className = "field-source"; + sourceEl.textContent = source; + label.appendChild(sourceEl); + } + + const input = inputForField(field); + input.id = `field-${field.key}`; + input.dataset.key = field.key; + input.dataset.original = field.value || ""; + input.dataset.secret = field.secret ? "true" : "false"; + input.dataset.configured = field.configured ? "true" : "false"; + input.disabled = field.locked; + input.addEventListener("input", updateDirtyState); + input.addEventListener("change", updateDirtyState); + + wrapper.append(label, input); + if (field.description) { + const description = document.createElement("div"); + description.className = "field-description"; + description.textContent = field.description; + wrapper.appendChild(description); + } + return wrapper; +} + +function inputForField(field) { + if (field.type === "boolean") { + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = String(field.value).toLowerCase() === "true"; + input.dataset.original = input.checked ? "true" : "false"; + return input; + } + + if (field.type === "tri_boolean") { + const select = document.createElement("select"); + [ + ["", "Inherit"], + ["true", "Enabled"], + ["false", "Disabled"], + ].forEach(([value, label]) => select.appendChild(option(value, label))); + select.value = field.value || ""; + return select; + } + + if (field.type === "select") { + const select = document.createElement("select"); + field.options.forEach((value) => select.appendChild(option(value, value))); + select.value = field.value || field.options[0] || ""; + return select; + } + + if (field.type === "textarea") { + const textarea = document.createElement("textarea"); + textarea.value = field.value || ""; + return textarea; + } + + const input = document.createElement("input"); + input.type = field.type === "number" ? "number" : "text"; + if (field.type === "secret") { + input.type = "password"; + input.placeholder = field.configured + ? "Configured - enter a new value to replace" + : "Not configured"; + input.value = ""; + input.autocomplete = "off"; + } else { + input.value = field.value || ""; + } + if (field.key.startsWith("MODEL")) { + input.setAttribute("list", "model-options"); + } + return input; +} + +function option(value, label) { + const optionEl = document.createElement("option"); + optionEl.value = value; + optionEl.textContent = label; + return optionEl; +} + +function readFieldValue(input) { + if (input.type === "checkbox") return input.checked ? "true" : "false"; + if (input.dataset.secret === "true" && input.dataset.configured === "true") { + return input.value ? input.value : MASKED_SECRET; + } + return input.value; +} + +function changedValues() { + const values = {}; + document.querySelectorAll("[data-key]").forEach((input) => { + if (input.disabled || !input.matches("input, select, textarea")) return; + const value = readFieldValue(input); + if (value !== input.dataset.original) { + values[input.dataset.key] = value; + } + }); + return values; +} + +function updateDirtyState() { + const count = Object.keys(changedValues()).length; + byId("dirtyState").textContent = + count === 0 ? "No changes" : `${count} unsaved change${count === 1 ? "" : "s"}`; + byId("applyButton").disabled = count === 0; +} + +async function validate(showResult = true) { + const result = await api("/admin/api/config/validate", { + method: "POST", + body: JSON.stringify({ values: changedValues() }), + }); + if (showResult) { + showValidationResult(result); + } + return result; +} + +function showValidationResult(result) { + if (result.valid) { + showMessage("Config shape is valid", "ok"); + } else { + showMessage(result.errors.join("; "), "error"); + } +} + +async function apply() { + const result = await api("/admin/api/config/apply", { + method: "POST", + body: JSON.stringify({ values: changedValues() }), + }); + if (!result.applied) { + showValidationResult(result); + return; + } + const restart = result.restart || {}; + if (restart.required && restart.automatic) { + showMessage("Applied. Restarting server...", "ok"); + byId("applyButton").disabled = true; + setTimeout(() => { + window.location.href = restart.admin_url || "/admin"; + }, 1600); + return; + } + const pending = restart.required ? restart.fields || [] : result.pending_fields || []; + await load(); + showMessage( + pending.length + ? `Applied. Restart fcc-server to use: ${pending.join(", ")}` + : "Applied", + "ok", + ); +} + +async function refreshLocalStatus() { + const result = await api("/admin/api/providers/local-status"); + result.providers.forEach((provider) => { + state.localStatus.set(provider.provider_id, provider); + const meta = provider.status_code + ? `${provider.base_url} returned HTTP ${provider.status_code}` + : provider.base_url; + updateProviderCard(provider.provider_id, provider.status, provider.label, meta); + }); +} + +async function testProvider(providerId, button) { + const original = button.textContent; + button.disabled = true; + button.textContent = "Testing"; + try { + const result = await api(`/admin/api/providers/${providerId}/test`, { + method: "POST", + body: "{}", + }); + if (result.ok) { + updateProviderCard( + providerId, + "reachable", + `${result.models.length} models`, + result.models.slice(0, 3).join(", ") || "No models returned", + ); + state.modelOptions = Array.from( + new Set([ + ...state.modelOptions, + ...result.models.map((model) => `${providerId}/${model}`), + ]), + ).sort(); + syncModelDatalist(); + } else { + updateProviderCard(providerId, "offline", result.error_type, result.error_type); + } + } finally { + button.disabled = false; + button.textContent = original; + } +} + +function syncModelDatalist() { + let datalist = byId("model-options"); + if (!datalist) { + datalist = document.createElement("datalist"); + datalist.id = "model-options"; + document.body.appendChild(datalist); + } + datalist.innerHTML = ""; + state.modelOptions.forEach((model) => datalist.appendChild(option(model, model))); +} + +function showMessage(message, kind = "") { + const area = byId("messageArea"); + area.textContent = message; + area.className = `message-area ${kind}`.trim(); +} + +byId("validateButton").addEventListener("click", () => validate(true)); +byId("applyButton").addEventListener("click", apply); + +load().catch((error) => { + showMessage(error.message, "error"); +}); diff --git a/src/free_claude_code/api/admin_static/index.html b/src/free_claude_code/api/admin_static/index.html new file mode 100644 index 0000000..92c86c2 --- /dev/null +++ b/src/free_claude_code/api/admin_static/index.html @@ -0,0 +1,77 @@ + + + + + + Free Claude Code Admin + + + + +
+ + +
+
+
+

Providers

+
+
+ +
+
+
+
+

Providers

+
+
+
+
+
+ + + + +
+
+ +
+
+ No changes + +
+
+
+ + +
+
+
+ + + diff --git a/src/free_claude_code/api/app.py b/src/free_claude_code/api/app.py new file mode 100644 index 0000000..330bdd1 --- /dev/null +++ b/src/free_claude_code/api/app.py @@ -0,0 +1,118 @@ +"""Pure FastAPI application factory.""" + +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.exception_handlers import request_validation_exception_handler +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from loguru import logger + +from free_claude_code.application.errors import ApplicationError +from free_claude_code.core.anthropic import anthropic_error_payload +from free_claude_code.core.diagnostics import ( + redacted_exception_traceback, + safe_exception_message, +) +from free_claude_code.core.openai_responses import openai_error_payload +from free_claude_code.core.trace import ( + extract_claude_session_id_from_headers, + trace_event, +) +from free_claude_code.core.version import package_version + +from .admin_routes import router as admin_router +from .ports import ApiServices +from .request_errors import ordinary_application_error_response +from .request_ids import ( + RequestCorrelationMiddleware, + attach_request_id_headers, + get_request_id, +) +from .routes import router +from .validation_log import summarize_request_validation_body + + +def create_app(services: ApiServices) -> FastAPI: + """Create the HTTP adapter around explicitly supplied runtime services.""" + app = FastAPI(title="Claude Code Proxy", version=package_version()) + app.state.services = services + app.add_middleware(RequestCorrelationMiddleware) + + app.include_router(admin_router) + app.include_router(router) + + @app.exception_handler(RequestValidationError) + async def validation_error_handler(request: Request, exc: RequestValidationError): + """Log request shape for 422 debugging without content values.""" + body: Any + try: + body = await request.json() + except Exception as error: + body = {"_json_error": type(error).__name__} + + message_summary, tool_names = summarize_request_validation_body(body) + trace_event( + stage="ingress", + event="server.request.validation_failed", + source="api", + path=request.url.path, + query=dict(request.query_params), + error_locs=[list(error.get("loc", ())) for error in exc.errors()], + error_types=[str(error.get("type", "")) for error in exc.errors()], + message_summary=message_summary, + tool_names=tool_names, + ) + return await request_validation_exception_handler(request, exc) + + @app.exception_handler(ApplicationError) + async def application_error_handler(request: Request, exc: ApplicationError): + """Serialize defensive application failures in the selected wire protocol.""" + return ordinary_application_error_response( + exc, + wire_api=( + "responses" if request.url.path == "/v1/responses" else "messages" + ), + request_id=get_request_id(request), + ) + + @app.exception_handler(Exception) + async def general_error_handler(request: Request, exc: Exception): + """Handle general errors and return Anthropic format.""" + request_id = get_request_id(request) + claude_sid = extract_claude_session_id_from_headers(request.headers) + settings = services.requests.current_settings() + with logger.contextualize( + http_method=request.method, + http_path=request.url.path, + claude_session_id=claude_sid, + request_id=request_id, + ): + if settings.log_api_error_tracebacks: + logger.error("General Error: {}", safe_exception_message(exc)) + logger.error(redacted_exception_traceback(exc)) + else: + logger.error( + "General Error: path={} method={} exc_type={}", + request.url.path, + request.method, + type(exc).__name__, + ) + message = safe_exception_message(exc) + if request.url.path == "/v1/responses": + content = openai_error_payload(message=message, error_type="api_error") + else: + content = anthropic_error_payload( + error_type="api_error", + message=message, + request_id=request_id, + ) + response = JSONResponse(status_code=500, content=content) + attach_request_id_headers( + response, + request_id=request_id, + path=request.url.path, + ) + return response + + return app diff --git a/src/free_claude_code/api/command_utils.py b/src/free_claude_code/api/command_utils.py new file mode 100644 index 0000000..951c163 --- /dev/null +++ b/src/free_claude_code/api/command_utils.py @@ -0,0 +1,164 @@ +"""Command parsing utilities for API optimizations.""" + +import re +import shlex + +_ENV_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=.*$") + + +def _is_env_assignment(part: str) -> bool: + """Return True when a token is a shell-style env assignment.""" + return bool(_ENV_ASSIGNMENT_RE.match(part)) + + +def _strip_env_assignments(parts: list[str]) -> list[str]: + """Return command parts after leading shell-style env assignments.""" + cmd_start = 0 + for i, part in enumerate(parts): + if _is_env_assignment(part): + cmd_start = i + 1 + else: + break + return parts[cmd_start:] + + +def extract_command_prefix(command: str) -> str: + """Extract the command prefix for fast prefix detection. + + Parses a shell command safely, handling environment variables and + command injection attempts. Returns the command prefix suitable + for quick identification. + + Returns: + Command prefix (e.g., "git", "git commit", "npm install") + or "none" if no valid command found + """ + if "`" in command or "$(" in command: + return "command_injection_detected" + + try: + parts = shlex.split(command, posix=False) + if not parts: + return "none" + + env_prefix = [] + cmd_start = 0 + for i, part in enumerate(parts): + if _is_env_assignment(part): + env_prefix.append(part) + cmd_start = i + 1 + else: + break + + if cmd_start >= len(parts): + return "none" + + cmd_parts = parts[cmd_start:] + if not cmd_parts: + return "none" + + first_word = cmd_parts[0] + two_word_commands = { + "git", + "npm", + "docker", + "kubectl", + "cargo", + "go", + "pip", + "yarn", + } + + if first_word in two_word_commands and len(cmd_parts) > 1: + second_word = cmd_parts[1] + if not second_word.startswith("-"): + return f"{first_word} {second_word}" + return first_word + return first_word if not env_prefix else " ".join(env_prefix) + " " + first_word + + except ValueError: + parts = command.split() + if not parts: + return "none" + cmd_parts = _strip_env_assignments(parts) + return cmd_parts[0] if cmd_parts else "none" + + +def extract_filepaths_from_command(command: str, output: str) -> str: + """Extract file paths from a command locally without API call. + + Determines if the command reads file contents and extracts paths accordingly. + Commands like ls/dir/find just list files, so return empty. + Commands like cat/head/tail actually read contents, so extract the file path. + + Returns: + Filepath extraction result in format + """ + listing_commands = { + "ls", + "dir", + "find", + "tree", + "pwd", + "cd", + "mkdir", + "rmdir", + "rm", + } + + reading_commands = {"cat", "head", "tail", "less", "more", "bat", "type"} + + try: + parts = shlex.split(command, posix=False) + if not parts: + return "\n" + + cmd_parts = _strip_env_assignments(parts) + if not cmd_parts: + return "\n" + + base_cmd = cmd_parts[0].split("/")[-1].split("\\")[-1].lower() + + if base_cmd in listing_commands: + return "\n" + + if base_cmd in reading_commands: + filepaths = [] + for part in cmd_parts[1:]: + if part.startswith("-"): + continue + filepaths.append(part) + + if filepaths: + paths_str = "\n".join(filepaths) + return f"\n{paths_str}\n" + return "\n" + + if base_cmd == "grep": + flags_with_args = {"-e", "-f", "-m", "-A", "-B", "-C"} + pattern_provided_via_flag = False + positional = [] + + skip_next = False + for part in cmd_parts[1:]: + if skip_next: + skip_next = False + continue + if part.startswith("-"): + if part in flags_with_args: + if part in {"-e", "-f"}: + pattern_provided_via_flag = True + skip_next = True + continue + positional.append(part) + + filepaths = positional if pattern_provided_via_flag else positional[1:] + if filepaths: + paths_str = "\n".join(filepaths) + return f"\n{paths_str}\n" + return "\n" + + return "\n" + + except ValueError: + return "\n" diff --git a/src/free_claude_code/api/dependencies.py b/src/free_claude_code/api/dependencies.py new file mode 100644 index 0000000..3557c60 --- /dev/null +++ b/src/free_claude_code/api/dependencies.py @@ -0,0 +1,74 @@ +"""FastAPI dependencies for the explicit runtime service boundary.""" + +import secrets + +from fastapi import Depends, HTTPException, Request +from loguru import logger + +from free_claude_code.application.errors import UnknownProviderError +from free_claude_code.application.ports import ProviderPort, RequestRuntimeLease +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.config.settings import Settings + +from .ports import ApiServices + + +def get_services(request: Request) -> ApiServices: + """Return the complete services supplied when the app was constructed.""" + return request.app.state.services + + +def get_settings(services: ApiServices = Depends(get_services)) -> Settings: + """Return the current request-runtime settings snapshot.""" + return services.requests.current_settings() + + +def resolve_provider( + provider_type: str, + *, + lease: RequestRuntimeLease, +) -> ProviderPort: + """Resolve a provider through one retained generation.""" + should_log_init = not lease.is_provider_cached(provider_type) + try: + provider = lease.resolve_provider(provider_type) + except UnknownProviderError: + logger.error( + "Unknown provider_type: '{}'. Supported: {}", + provider_type, + ", ".join(f"'{key}'" for key in PROVIDER_CATALOG), + ) + raise + if should_log_init: + logger.info("Provider initialized: {}", provider_type) + return provider + + +def require_api_key( + request: Request, + settings: Settings = Depends(get_settings), +) -> None: + """Require the configured Anthropic-style server API key.""" + anthropic_auth_token = settings.anthropic_auth_token.strip() + if not anthropic_auth_token: + return + + header = ( + request.headers.get("x-api-key") + or request.headers.get("authorization") + or request.headers.get("anthropic-auth-token") + ) + if not header: + raise HTTPException(status_code=401, detail="Missing API key") + + token = header.strip() + if header.lower().startswith("bearer "): + token = header.split(" ", 1)[1].strip() + if token and ":" in token: + token = token.split(":", 1)[0].strip() + + if not secrets.compare_digest( + token.encode("utf-8"), + anthropic_auth_token.encode("utf-8"), + ): + raise HTTPException(status_code=401, detail="Invalid API key") diff --git a/src/free_claude_code/api/detection.py b/src/free_claude_code/api/detection.py new file mode 100644 index 0000000..1dfbc81 --- /dev/null +++ b/src/free_claude_code/api/detection.py @@ -0,0 +1,150 @@ +"""Request detection utilities for API optimizations. + +Detects quota checks, title generation, prefix detection, safety classifier, +suggestion mode, and filepath extraction requests to enable targeted handling. +""" + +from free_claude_code.core.anthropic import MessagesRequest, extract_text_from_content + + +def is_quota_check_request(request_data: MessagesRequest) -> bool: + """Check if this is a quota probe request. + + Quota checks are typically simple requests with max_tokens=1 + and a single message containing the word "quota". + """ + if ( + request_data.max_tokens == 1 + and len(request_data.messages) == 1 + and request_data.messages[0].role == "user" + ): + text = extract_text_from_content(request_data.messages[0].content) + if "quota" in text.lower(): + return True + return False + + +def is_title_generation_request(request_data: MessagesRequest) -> bool: + """Check if this is a conversation title generation request. + + Title generation requests are detected by a system prompt containing + title extraction instructions, no tools, and a single user message. + + Matches Claude Code session title prompts (sentence-case title, JSON + \"title\" field, etc.). + """ + if not request_data.system or request_data.tools: + return False + system_text = extract_text_from_content(request_data.system).lower() + if "title" not in system_text: + return False + return "sentence-case title" in system_text or ( + "return json" in system_text + and "field" in system_text + and ("coding session" in system_text or "this session" in system_text) + ) + + +def is_prefix_detection_request(request_data: MessagesRequest) -> tuple[bool, str]: + """Check if this is a fast prefix detection request. + + Prefix detection requests contain a policy_spec block and + a Command: section for extracting shell command prefixes. + + Returns: + Tuple of (is_prefix_request, command_string) + """ + if len(request_data.messages) != 1 or request_data.messages[0].role != "user": + return False, "" + + content = extract_text_from_content(request_data.messages[0].content) + + if "" in content and "Command:" in content: + try: + cmd_start = content.rfind("Command:") + len("Command:") + return True, content[cmd_start:].strip() + except TypeError: + return False, "" + + return False, "" + + +def is_safety_classifier_request(request_data: MessagesRequest) -> bool: + """Return whether this is Claude Code's auto-mode safety classifier prompt.""" + if request_data.tools: + return False + + system_text = ( + extract_text_from_content(request_data.system) if request_data.system else "" + ) + messages_text = "".join( + extract_text_from_content(message.content) for message in request_data.messages + ) + combined = f"{system_text}\n{messages_text}" + has_verdict_instruction = "yes" in combined or "no" in combined + return "" in combined and has_verdict_instruction + + +def is_suggestion_mode_request(request_data: MessagesRequest) -> bool: + """Check if this is a suggestion mode request. + + Suggestion mode requests contain "[SUGGESTION MODE:" in the user's message, + used for auto-suggesting what the user might type next. + """ + for msg in request_data.messages: + if msg.role == "user": + text = extract_text_from_content(msg.content) + if "[SUGGESTION MODE:" in text: + return True + return False + + +def is_filepath_extraction_request( + request_data: MessagesRequest, +) -> tuple[bool, str, str]: + """Check if this is a filepath extraction request. + + Filepath extraction requests have a single user message with + "Command:" and "Output:" sections, asking to extract file paths + from command output. + + Returns: + Tuple of (is_filepath_request, command, output) + """ + if len(request_data.messages) != 1 or request_data.messages[0].role != "user": + return False, "", "" + if request_data.tools: + return False, "", "" + + content = extract_text_from_content(request_data.messages[0].content) + + if "Command:" not in content or "Output:" not in content: + return False, "", "" + + # Match if user content OR system block indicates filepath extraction + user_has_filepaths = ( + "filepaths" in content.lower() or "" in content.lower() + ) + system_text = ( + extract_text_from_content(request_data.system) if request_data.system else "" + ) + system_has_extract = ( + "extract any file paths" in system_text.lower() + or "file paths that this command" in system_text.lower() + ) + if not user_has_filepaths and not system_has_extract: + return False, "", "" + + cmd_start = content.find("Command:") + len("Command:") + output_marker = content.find("Output:", cmd_start) + if output_marker == -1: + return False, "", "" + + command = content[cmd_start:output_marker].strip() + output = content[output_marker + len("Output:") :].strip() + + for marker in ["<", "\n\n"]: + if marker in output: + output = output.split(marker)[0].strip() + + return True, command, output diff --git a/src/free_claude_code/api/handlers/__init__.py b/src/free_claude_code/api/handlers/__init__.py new file mode 100644 index 0000000..e14c5d7 --- /dev/null +++ b/src/free_claude_code/api/handlers/__init__.py @@ -0,0 +1,7 @@ +"""Product-flow handlers for public API routes.""" + +from .messages import MessagesHandler +from .responses import ResponsesHandler +from .token_count import TokenCountHandler + +__all__ = ["MessagesHandler", "ResponsesHandler", "TokenCountHandler"] diff --git a/src/free_claude_code/api/handlers/messages.py b/src/free_claude_code/api/handlers/messages.py new file mode 100644 index 0000000..36ea7e6 --- /dev/null +++ b/src/free_claude_code/api/handlers/messages.py @@ -0,0 +1,358 @@ +"""Claude Messages API product flow.""" + +import asyncio +from collections.abc import AsyncIterator, Callable +from dataclasses import dataclass, replace + +from fastapi.responses import JSONResponse, Response +from loguru import logger + +from free_claude_code.api.detection import is_safety_classifier_request +from free_claude_code.api.optimization_handlers import try_optimizations +from free_claude_code.api.request_errors import ( + http_status_for_unexpected_api_exception, + log_unexpected_api_exception, + require_non_empty_messages, + unexpected_http_exception, +) +from free_claude_code.api.request_ids import new_request_id +from free_claude_code.api.response_streams import ( + EmptyStreamError, + anthropic_sse_streaming_response, + terminal_execution_error_response, + trace_terminal_execution_error, +) +from free_claude_code.api.web_tools.egress import ( + WebFetchEgressPolicy, + web_fetch_allowed_scheme_set, +) +from free_claude_code.api.web_tools.request import ( + is_web_server_tool_request, + unsupported_server_tool_error, +) +from free_claude_code.api.web_tools.streaming import stream_web_server_tool_response +from free_claude_code.application.errors import ApplicationError, InvalidRequestError +from free_claude_code.application.execution import ProviderExecutor, TokenCounter +from free_claude_code.application.ports import ProviderResolver +from free_claude_code.application.routing import ModelRouter, RoutedMessagesRequest +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import ( + MessagesRequest, + aggregate_anthropic_sse_to_message, + anthropic_error_payload, + anthropic_error_type_for_failure, + anthropic_failure_payload, + anthropic_status_for_error_type, + get_token_count, +) +from free_claude_code.core.diagnostics import safe_exception_message +from free_claude_code.core.failures import ExecutionFailure, find_execution_failure +from free_claude_code.core.trace import trace_event + + +@dataclass(frozen=True) +class _MessagesStreamResult: + body: AsyncIterator[str] + + +@dataclass(frozen=True) +class _MessagesCompleteResult: + response: object + + +_MessagesResult = _MessagesStreamResult | _MessagesCompleteResult +MessageIntercept = Callable[[RoutedMessagesRequest], _MessagesResult | None] + + +class MessagesHandler: + """Handle Anthropic-compatible Messages requests.""" + + def __init__( + self, + settings: Settings, + provider_resolver: ProviderResolver, + *, + model_router: ModelRouter | None = None, + token_counter: TokenCounter = get_token_count, + provider_executor: ProviderExecutor | None = None, + generation_id: int | None = None, + ) -> None: + self._settings = settings + self._model_router = model_router or ModelRouter(settings) + self._token_counter = token_counter + self._provider_executor = provider_executor or ProviderExecutor( + provider_resolver, + token_counter=token_counter, + generation_id=generation_id, + log_raw_payloads=settings.log_raw_api_payloads, + ) + self._message_intercepts: tuple[MessageIntercept, ...] = ( + self._intercept_web_server_tool, + self._intercept_local_optimization, + ) + + async def create( + self, request_data: MessagesRequest, *, request_id: str | None = None + ) -> object: + """Create an Anthropic-compatible message response.""" + request_id = request_id or new_request_id() + try: + require_non_empty_messages(request_data.messages) + routed = self._model_router.resolve_messages_request(request_data) + routed = self._apply_message_routing_policies(routed) + self._reject_unsupported_server_tools(routed) + + result = self._run_message_intercepts(routed) + if result is None: + logger.debug("No optimization matched, routing to provider") + result = _MessagesStreamResult( + self._provider_executor.stream( + routed, + wire_api="messages", + raw_log_label="FULL_PAYLOAD", + raw_log_payload=routed.request.model_dump(), + request_id=request_id, + ) + ) + return await self._to_public_response( + result, + stream=request_data.stream, + request_id=request_id, + ) + except ApplicationError: + raise + except ExecutionFailure as exc: + return self._execution_failure_response(exc, request_id=request_id) + except Exception as exc: + failure = find_execution_failure(exc) + if failure is not None: + return self._execution_failure_response(failure, request_id=request_id) + raise unexpected_http_exception( + self._settings, exc, context="CREATE_MESSAGE_ERROR" + ) from exc + + async def _to_public_response( + self, + result: _MessagesResult, + *, + stream: bool | None, + request_id: str, + ) -> object: + if isinstance(result, _MessagesCompleteResult): + return result.response + if stream is False: + # Non-streaming clients (e.g. Claude Code utility calls) need a + # complete JSON Message; the internal pipeline is always SSE, so + # serving that raw here breaks the client SDK's response parse. + try: + message, error = await aggregate_anthropic_sse_to_message(result.body) + except GeneratorExit: + raise + except asyncio.CancelledError: + raise + except ExecutionFailure as exc: + return self._execution_failure_response(exc, request_id=request_id) + except BaseExceptionGroup as exc: + failure = find_execution_failure(exc) + if failure is not None: + return self._execution_failure_response( + failure, request_id=request_id + ) + return self._unexpected_execution_error_response( + exc, + request_id=request_id, + context="CREATE_MESSAGE_NON_STREAM_ERROR", + ) + except Exception as exc: + return self._unexpected_execution_error_response( + exc, + request_id=request_id, + context="CREATE_MESSAGE_NON_STREAM_ERROR", + ) + if error is not None: + error_type, message_text = _stream_error_fields(error) + status_code = anthropic_status_for_error_type(error_type) + trace_terminal_execution_error( + wire_api="messages", + request_id=request_id, + status_code=status_code, + error_type=error_type, + ) + return terminal_execution_error_response( + status_code=status_code, + content=anthropic_error_payload( + error_type=error_type, + message=message_text, + request_id=request_id, + ), + ) + return JSONResponse(content=message) + return await anthropic_sse_streaming_response( + result.body, + pre_start_error_response=lambda exc: self._pre_start_error_response( + exc, request_id=request_id + ), + request_id=request_id, + ) + + def _pre_start_error_response( + self, exc: BaseException, *, request_id: str + ) -> Response: + failure = find_execution_failure(exc) + if failure is not None: + return self._execution_failure_response(failure, request_id=request_id) + context = ( + "CREATE_MESSAGE_EMPTY_STREAM" + if isinstance(exc, EmptyStreamError) + else "CREATE_MESSAGE_STREAM_START_ERROR" + ) + return self._unexpected_execution_error_response( + exc, + request_id=request_id, + context=context, + ) + + def _execution_failure_response( + self, failure: ExecutionFailure, *, request_id: str + ) -> JSONResponse: + error_type = anthropic_error_type_for_failure(failure) + trace_terminal_execution_error( + wire_api="messages", + request_id=request_id, + status_code=failure.status_code, + error_type=error_type, + error=failure, + ) + return terminal_execution_error_response( + status_code=failure.status_code, + content=anthropic_failure_payload(failure, request_id=request_id), + ) + + def _unexpected_execution_error_response( + self, + exc: BaseException, + *, + request_id: str, + context: str, + ) -> JSONResponse: + log_unexpected_api_exception( + self._settings, + exc, + context=context, + request_id=request_id, + ) + status_code = http_status_for_unexpected_api_exception(exc) + trace_terminal_execution_error( + wire_api="messages", + request_id=request_id, + status_code=status_code, + error_type="api_error", + error=exc, + ) + return terminal_execution_error_response( + status_code=status_code, + content=anthropic_error_payload( + error_type="api_error", + message=safe_exception_message(exc), + request_id=request_id, + ), + ) + + def _reject_unsupported_server_tools(self, routed: RoutedMessagesRequest) -> None: + tool_err = unsupported_server_tool_error( + routed.request, + web_tools_enabled=self._settings.enable_web_server_tools, + ) + if tool_err is not None: + raise InvalidRequestError(tool_err) + + def _apply_message_routing_policies( + self, routed: RoutedMessagesRequest + ) -> RoutedMessagesRequest: + if not is_safety_classifier_request(routed.request): + return routed + changed = routed.resolved.thinking_enabled + trace_event( + stage="routing", + event="free_claude_code.api.optimization.safety_classifier_no_thinking", + source="api", + model=routed.request.model, + changed=changed, + ) + if not changed: + return routed + return RoutedMessagesRequest( + request=routed.request, + resolved=replace(routed.resolved, thinking_enabled=False), + ) + + def _run_message_intercepts( + self, routed: RoutedMessagesRequest + ) -> _MessagesResult | None: + for intercept in self._message_intercepts: + result = intercept(routed) + if result is not None: + return result + return None + + def _intercept_web_server_tool( + self, routed: RoutedMessagesRequest + ) -> _MessagesResult | None: + if not self._settings.enable_web_server_tools: + return None + if not is_web_server_tool_request(routed.request): + return None + + input_tokens = self._token_counter( + routed.request.messages, routed.request.system, routed.request.tools + ) + trace_event( + stage="routing", + event="free_claude_code.api.optimization.web_server_tool", + source="api", + model=routed.request.model, + ) + egress = WebFetchEgressPolicy( + allow_private_network_targets=self._settings.web_fetch_allow_private_networks, + allowed_schemes=web_fetch_allowed_scheme_set( + self._settings.web_fetch_allowed_schemes + ), + ) + return _MessagesStreamResult( + stream_web_server_tool_response( + routed.request, + input_tokens=input_tokens, + web_fetch_egress=egress, + verbose_client_errors=self._settings.log_api_error_tracebacks, + ), + ) + + def _intercept_local_optimization( + self, routed: RoutedMessagesRequest + ) -> _MessagesResult | None: + optimized = try_optimizations(routed.request, self._settings) + if optimized is None: + return None + trace_event( + stage="routing", + event="free_claude_code.api.optimization.short_circuit", + source="api", + model=routed.request.model, + ) + return _MessagesCompleteResult(optimized) + + +def _stream_error_fields(error: dict[str, object]) -> tuple[str, str]: + raw_type = error.get("type") + error_type = ( + raw_type.strip() + if isinstance(raw_type, str) and raw_type.strip() + else "api_error" + ) + raw_message = error.get("message") + message = ( + raw_message.strip() + if isinstance(raw_message, str) and raw_message.strip() + else "Provider request failed unexpectedly." + ) + return error_type, message diff --git a/src/free_claude_code/api/handlers/responses.py b/src/free_claude_code/api/handlers/responses.py new file mode 100644 index 0000000..b2ffcef --- /dev/null +++ b/src/free_claude_code/api/handlers/responses.py @@ -0,0 +1,183 @@ +"""OpenAI Responses API product flow for Codex clients.""" + +from fastapi.responses import JSONResponse + +from free_claude_code.api.request_errors import ( + http_status_for_unexpected_api_exception, + log_unexpected_api_exception, + require_non_empty_messages, +) +from free_claude_code.api.request_ids import new_request_id +from free_claude_code.api.response_streams import ( + openai_responses_sse_streaming_response, + terminal_execution_error_response, + trace_terminal_execution_error, +) +from free_claude_code.application.errors import ApplicationError, InvalidRequestError +from free_claude_code.application.execution import ProviderExecutor +from free_claude_code.application.ports import ProviderResolver +from free_claude_code.application.routing import ModelRouter +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import MessagesRequest +from free_claude_code.core.diagnostics import safe_exception_message +from free_claude_code.core.failures import ExecutionFailure, find_execution_failure +from free_claude_code.core.openai_responses import ( + OpenAIResponsesAdapter, + OpenAIResponsesRequest, + openai_error_type_for_failure, + openai_failure_payload, +) + + +class ResponsesHandler: + """Handle streaming OpenAI Responses-compatible requests.""" + + def __init__( + self, + settings: Settings, + provider_resolver: ProviderResolver, + *, + model_router: ModelRouter | None = None, + responses_adapter: OpenAIResponsesAdapter | None = None, + provider_executor: ProviderExecutor | None = None, + generation_id: int | None = None, + ) -> None: + self._settings = settings + self._model_router = model_router or ModelRouter(settings) + self._responses_adapter = responses_adapter or OpenAIResponsesAdapter() + self._provider_executor = provider_executor or ProviderExecutor( + provider_resolver, + generation_id=generation_id, + log_raw_payloads=settings.log_raw_api_payloads, + ) + + async def create( + self, request_data: OpenAIResponsesRequest, *, request_id: str | None = None + ) -> object: + """Create a streaming OpenAI Responses-compatible response.""" + request_id = request_id or new_request_id() + request_payload = request_data.model_dump(mode="json", exclude_none=True) + if request_data.stream is False: + raise InvalidRequestError( + "FCC /v1/responses supports streaming only; omit stream or set stream=true." + ) + + try: + anthropic_payload = self._responses_adapter.to_anthropic_payload( + request_data + ) + response_request = MessagesRequest(**anthropic_payload) + require_non_empty_messages(response_request.messages) + routed = self._model_router.resolve_messages_request(response_request) + + streamed = self._provider_executor.stream( + routed, + wire_api="responses", + raw_log_label="FULL_RESPONSES_PAYLOAD", + raw_log_payload=request_payload, + request_id=request_id, + ) + return await openai_responses_sse_streaming_response( + self._responses_adapter.iter_sse_from_anthropic( + streamed, + request_data, + on_post_start_terminal_failure=lambda exc: ( + self._trace_post_start_terminal_failure( + exc, + request_id=request_id, + ) + ), + ), + headers=self._responses_adapter.sse_headers, + pre_start_error_response=lambda exc: self._pre_start_error_response( + exc, request_id=request_id + ), + ) + except OpenAIResponsesAdapter.ConversionError as exc: + raise InvalidRequestError(str(exc)) from exc + except ApplicationError: + raise + except ExecutionFailure as exc: + return self._execution_failure_response(exc, request_id=request_id) + except Exception as exc: + failure = find_execution_failure(exc) + if failure is not None: + return self._execution_failure_response(failure, request_id=request_id) + log_unexpected_api_exception( + self._settings, + exc, + context="CREATE_RESPONSE_ERROR", + ) + return JSONResponse( + status_code=http_status_for_unexpected_api_exception(exc), + content=self._responses_adapter.error_payload( + message=safe_exception_message(exc), + error_type="api_error", + ), + ) + + def _pre_start_error_response( + self, exc: BaseException, *, request_id: str + ) -> JSONResponse: + failure = find_execution_failure(exc) + if failure is not None: + return self._execution_failure_response(failure, request_id=request_id) + log_unexpected_api_exception( + self._settings, + exc, + context="CREATE_RESPONSE_STREAM_START_ERROR", + request_id=request_id, + ) + status_code = http_status_for_unexpected_api_exception(exc) + trace_terminal_execution_error( + wire_api="responses", + request_id=request_id, + status_code=status_code, + error_type="api_error", + error=exc, + ) + return terminal_execution_error_response( + status_code=status_code, + content=self._responses_adapter.error_payload( + message=safe_exception_message(exc), + error_type="api_error", + ), + ) + + def _execution_failure_response( + self, + failure: ExecutionFailure, + *, + request_id: str, + ) -> JSONResponse: + error_type = openai_error_type_for_failure(failure) + trace_terminal_execution_error( + wire_api="responses", + request_id=request_id, + status_code=failure.status_code, + error_type=error_type, + error=failure, + ) + return terminal_execution_error_response( + status_code=failure.status_code, + content=openai_failure_payload(failure), + ) + + @staticmethod + def _trace_post_start_terminal_failure( + exc: BaseException, + *, + request_id: str, + ) -> None: + failure = find_execution_failure(exc) + trace_terminal_execution_error( + wire_api="responses", + request_id=request_id, + status_code=failure.status_code if failure is not None else 500, + error_type=( + openai_error_type_for_failure(failure) + if failure is not None + else "api_error" + ), + error=exc, + ) diff --git a/src/free_claude_code/api/handlers/token_count.py b/src/free_claude_code/api/handlers/token_count.py new file mode 100644 index 0000000..3ee838d --- /dev/null +++ b/src/free_claude_code/api/handlers/token_count.py @@ -0,0 +1,85 @@ +"""Anthropic token-count API product flow.""" + +from fastapi import HTTPException +from loguru import logger + +from free_claude_code.api.request_errors import ( + http_status_for_unexpected_api_exception, + log_unexpected_api_exception, + require_non_empty_messages, +) +from free_claude_code.api.request_ids import new_request_id +from free_claude_code.application.errors import ApplicationError +from free_claude_code.application.execution import TokenCounter +from free_claude_code.application.routing import ModelRouter +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import ( + TokenCountRequest, + TokenCountResponse, + anthropic_request_snapshot, + get_token_count, +) +from free_claude_code.core.diagnostics import safe_exception_message +from free_claude_code.core.trace import trace_event + + +class TokenCountHandler: + """Handle Anthropic-compatible token count requests.""" + + def __init__( + self, + settings: Settings, + *, + model_router: ModelRouter | None = None, + token_counter: TokenCounter = get_token_count, + ) -> None: + self._settings = settings + self._model_router = model_router or ModelRouter(settings) + self._token_counter = token_counter + + def count( + self, request_data: TokenCountRequest, *, request_id: str | None = None + ) -> TokenCountResponse: + """Count tokens for a request after applying configured model routing.""" + request_id = request_id or new_request_id() + with logger.contextualize(request_id=request_id): + try: + require_non_empty_messages(request_data.messages) + routed = self._model_router.resolve_token_count_request(request_data) + tokens = self._token_counter( + routed.request.messages, routed.request.system, routed.request.tools + ) + trace_event( + stage="routing", + event="free_claude_code.api.route.resolved", + source="api", + request_id=request_id, + kind="count_tokens", + provider_id=routed.resolved.provider_id, + provider_model=routed.resolved.provider_model, + provider_model_ref=routed.resolved.provider_model_ref, + gateway_model=routed.request.model, + ) + trace_event( + stage="ingress", + event="free_claude_code.api.count_tokens.completed", + source="api", + request_id=request_id, + message_count=len(routed.request.messages), + input_tokens=tokens, + snapshot=anthropic_request_snapshot(routed.request), + ) + return TokenCountResponse(input_tokens=tokens) + except ApplicationError: + raise + except Exception as exc: + log_unexpected_api_exception( + self._settings, + exc, + context="COUNT_TOKENS_ERROR", + request_id=request_id, + ) + raise HTTPException( + status_code=http_status_for_unexpected_api_exception(exc), + detail=safe_exception_message(exc), + ) from exc diff --git a/src/free_claude_code/api/model_catalog.py b/src/free_claude_code/api/model_catalog.py new file mode 100644 index 0000000..210531f --- /dev/null +++ b/src/free_claude_code/api/model_catalog.py @@ -0,0 +1,152 @@ +"""Model-list response construction for Claude-compatible clients.""" + +from typing import Literal + +from pydantic import BaseModel + +from free_claude_code.application.ports import RequestRuntimePort +from free_claude_code.config.model_refs import configured_chat_model_refs +from free_claude_code.config.settings import Settings +from free_claude_code.core.gateway_model_ids import ( + gateway_model_id, + no_thinking_gateway_model_id, +) + +DISCOVERED_MODEL_CREATED_AT = "1970-01-01T00:00:00Z" + + +class ModelResponse(BaseModel): + object: Literal["model"] = "model" + created: int = 0 + owned_by: str = "free-claude-code" + created_at: str + display_name: str + id: str + type: Literal["model"] = "model" + + +class ModelsListResponse(BaseModel): + object: Literal["list"] = "list" + data: list[ModelResponse] + first_id: str | None + has_more: bool + last_id: str | None + + +SUPPORTED_CLAUDE_MODELS = [ + ModelResponse( + id="claude-opus-4-20250514", + display_name="Claude Opus 4", + created_at="2025-05-14T00:00:00Z", + ), + ModelResponse( + id="claude-sonnet-4-20250514", + display_name="Claude Sonnet 4", + created_at="2025-05-14T00:00:00Z", + ), + ModelResponse( + id="claude-haiku-4-20250514", + display_name="Claude Haiku 4", + created_at="2025-05-14T00:00:00Z", + ), + ModelResponse( + id="claude-3-opus-20240229", + display_name="Claude 3 Opus", + created_at="2024-02-29T00:00:00Z", + ), + ModelResponse( + id="claude-3-5-sonnet-20241022", + display_name="Claude 3.5 Sonnet", + created_at="2024-10-22T00:00:00Z", + ), + ModelResponse( + id="claude-3-haiku-20240307", + display_name="Claude 3 Haiku", + created_at="2024-03-07T00:00:00Z", + ), + ModelResponse( + id="claude-3-5-haiku-20241022", + display_name="Claude 3.5 Haiku", + created_at="2024-10-22T00:00:00Z", + ), +] + + +def build_models_list_response( + settings: Settings, runtime: RequestRuntimePort +) -> ModelsListResponse: + """Return configured, cached, and compatibility model ids.""" + models: list[ModelResponse] = [] + seen: set[str] = set() + + for ref in configured_chat_model_refs(settings): + supports_thinking = runtime.cached_model_supports_thinking( + ref.provider_id, ref.model_id + ) + _append_provider_model_variants( + models, + seen, + ref.model_ref, + supports_thinking=supports_thinking, + ) + + for model_info in runtime.cached_prefixed_model_infos(): + _append_provider_model_variants( + models, + seen, + model_info.model_id, + supports_thinking=model_info.supports_thinking, + ) + + for model in SUPPORTED_CLAUDE_MODELS: + _append_unique_model(models, seen, model) + + return ModelsListResponse( + data=models, + first_id=models[0].id if models else None, + has_more=False, + last_id=models[-1].id if models else None, + ) + + +def _discovered_model_response(model_id: str, *, display_name: str) -> ModelResponse: + return ModelResponse( + id=model_id, + display_name=display_name, + created_at=DISCOVERED_MODEL_CREATED_AT, + ) + + +def _append_unique_model( + models: list[ModelResponse], seen: set[str], model: ModelResponse +) -> None: + if model.id in seen: + return + seen.add(model.id) + models.append(model) + + +def _append_provider_model_variants( + models: list[ModelResponse], + seen: set[str], + provider_model_ref: str, + *, + supports_thinking: bool | None = None, +) -> None: + if supports_thinking is not False: + _append_unique_model( + models, + seen, + _discovered_model_response( + gateway_model_id(provider_model_ref), + display_name=provider_model_ref, + ), + ) + _append_unique_model( + models, + seen, + _discovered_model_response( + no_thinking_gateway_model_id(provider_model_ref), + display_name=f"{provider_model_ref} (no thinking)", + ), + ) diff --git a/src/free_claude_code/api/optimization_handlers.py b/src/free_claude_code/api/optimization_handlers.py new file mode 100644 index 0000000..452dadc --- /dev/null +++ b/src/free_claude_code/api/optimization_handlers.py @@ -0,0 +1,157 @@ +"""Optimization handlers for fast-path API responses. + +Each handler returns a MessagesResponse if the request matches and the +optimization is enabled, otherwise None. +""" + +import uuid + +from loguru import logger + +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import ( + MessagesRequest, + MessagesResponse, + Usage, +) + +from .command_utils import extract_command_prefix, extract_filepaths_from_command +from .detection import ( + is_filepath_extraction_request, + is_prefix_detection_request, + is_quota_check_request, + is_suggestion_mode_request, + is_title_generation_request, +) + + +def _text_response( + request_data: MessagesRequest, + text: str, + *, + input_tokens: int, + output_tokens: int, +) -> MessagesResponse: + return MessagesResponse( + id=f"msg_{uuid.uuid4()}", + model=request_data.model, + content=[{"type": "text", "text": text}], + stop_reason="end_turn", + usage=Usage(input_tokens=input_tokens, output_tokens=output_tokens), + ) + + +def try_prefix_detection( + request_data: MessagesRequest, settings: Settings +) -> MessagesResponse | None: + """Fast prefix detection - return command prefix without API call.""" + if not settings.fast_prefix_detection: + return None + + is_prefix_req, command = is_prefix_detection_request(request_data) + if not is_prefix_req: + return None + + logger.info("Optimization: Fast prefix detection request") + return _text_response( + request_data, + extract_command_prefix(command), + input_tokens=100, + output_tokens=5, + ) + + +def try_quota_mock( + request_data: MessagesRequest, settings: Settings +) -> MessagesResponse | None: + """Mock quota probe requests.""" + if not settings.enable_network_probe_mock: + return None + if not is_quota_check_request(request_data): + return None + + logger.info("Optimization: Intercepted and mocked quota probe") + return _text_response( + request_data, + "Quota check passed.", + input_tokens=10, + output_tokens=5, + ) + + +def try_title_skip( + request_data: MessagesRequest, settings: Settings +) -> MessagesResponse | None: + """Skip title generation requests.""" + if not settings.enable_title_generation_skip: + return None + if not is_title_generation_request(request_data): + return None + + logger.info("Optimization: Skipped title generation request") + return _text_response( + request_data, + "Conversation", + input_tokens=100, + output_tokens=5, + ) + + +def try_suggestion_skip( + request_data: MessagesRequest, settings: Settings +) -> MessagesResponse | None: + """Skip suggestion mode requests.""" + if not settings.enable_suggestion_mode_skip: + return None + if not is_suggestion_mode_request(request_data): + return None + + logger.info("Optimization: Skipped suggestion mode request") + return _text_response( + request_data, + "", + input_tokens=100, + output_tokens=1, + ) + + +def try_filepath_mock( + request_data: MessagesRequest, settings: Settings +) -> MessagesResponse | None: + """Mock filepath extraction requests.""" + if not settings.enable_filepath_extraction_mock: + return None + + is_fp, cmd, output = is_filepath_extraction_request(request_data) + if not is_fp: + return None + + filepaths = extract_filepaths_from_command(cmd, output) + logger.info("Optimization: Mocked filepath extraction") + return _text_response( + request_data, + filepaths, + input_tokens=100, + output_tokens=10, + ) + + +# Cheapest/most common optimizations first for faster short-circuit. +OPTIMIZATION_HANDLERS = [ + try_quota_mock, + try_prefix_detection, + try_title_skip, + try_suggestion_skip, + try_filepath_mock, +] + + +def try_optimizations( + request_data: MessagesRequest, settings: Settings +) -> MessagesResponse | None: + """Run optimization handlers in order. Returns first match or None.""" + for handler in OPTIMIZATION_HANDLERS: + result = handler(request_data, settings) + if result is not None: + return result + return None diff --git a/src/free_claude_code/api/ports.py b/src/free_claude_code/api/ports.py new file mode 100644 index 0000000..778f3d9 --- /dev/null +++ b/src/free_claude_code/api/ports.py @@ -0,0 +1,32 @@ +"""Runtime capabilities consumed by the HTTP API adapter.""" + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Protocol + +from free_claude_code.application.ports import RequestRuntimePort, TaskController + + +class AdminRuntimePort(Protocol): + """Runtime operations exposed by the local Admin API.""" + + async def apply_admin_config( + self, updates: Mapping[str, Any] + ) -> dict[str, Any]: ... + + def admin_status(self) -> dict[str, Any]: ... + + async def test_provider(self, provider_id: str) -> dict[str, Any]: ... + + async def refresh_models(self) -> dict[str, Any]: ... + + async def request_restart(self) -> None: ... + + +@dataclass(frozen=True, slots=True) +class ApiServices: + """Complete runtime boundary required to construct the API application.""" + + requests: RequestRuntimePort + admin: AdminRuntimePort + tasks: TaskController diff --git a/src/free_claude_code/api/request_errors.py b/src/free_claude_code/api/request_errors.py new file mode 100644 index 0000000..4e67fa5 --- /dev/null +++ b/src/free_claude_code/api/request_errors.py @@ -0,0 +1,99 @@ +"""Shared API request validation and safe error logging.""" + +from typing import Any, Literal + +from fastapi import HTTPException +from fastapi.responses import JSONResponse +from loguru import logger + +from free_claude_code.application.errors import ApplicationError, InvalidRequestError +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import ( + anthropic_error_payload, + anthropic_error_type_for_failure, +) +from free_claude_code.core.diagnostics import ( + redacted_exception_traceback, + safe_exception_message, +) +from free_claude_code.core.openai_responses import ( + openai_error_payload, + openai_error_type_for_failure, +) + +WireApi = Literal["messages", "responses"] + + +def require_non_empty_messages(messages: list[Any]) -> None: + if not messages: + raise InvalidRequestError("messages cannot be empty") + + +def ordinary_application_error_response( + error: ApplicationError, + *, + wire_api: WireApi, + request_id: str, +) -> JSONResponse: + """Serialize a deterministic application error without terminal headers.""" + if wire_api == "responses": + return JSONResponse( + status_code=error.status_code, + content=openai_error_payload( + message=error.message, + error_type=openai_error_type_for_failure(error.kind), + ), + ) + return JSONResponse( + status_code=error.status_code, + content=anthropic_error_payload( + error_type=anthropic_error_type_for_failure(error.kind), + message=error.message, + request_id=request_id, + ), + ) + + +def http_status_for_unexpected_api_exception(_exc: BaseException) -> int: + return 500 + + +def log_unexpected_api_exception( + settings: Settings, + exc: BaseException, + *, + context: str, + request_id: str | None = None, +) -> None: + """Log API failures without echoing exception text unless opted in.""" + if settings.log_api_error_tracebacks: + if request_id is not None: + logger.error( + "{} request_id={}: {}", + context, + request_id, + safe_exception_message(exc), + ) + else: + logger.error("{}: {}", context, safe_exception_message(exc)) + logger.error(redacted_exception_traceback(exc)) + return + if request_id is not None: + logger.error( + "{} request_id={} exc_type={}", + context, + request_id, + type(exc).__name__, + ) + else: + logger.error("{} exc_type={}", context, type(exc).__name__) + + +def unexpected_http_exception( + settings: Settings, exc: Exception, *, context: str +) -> HTTPException: + log_unexpected_api_exception(settings, exc, context=context) + return HTTPException( + status_code=http_status_for_unexpected_api_exception(exc), + detail=safe_exception_message(exc), + ) diff --git a/src/free_claude_code/api/request_ids.py b/src/free_claude_code/api/request_ids.py new file mode 100644 index 0000000..6ffe5ae --- /dev/null +++ b/src/free_claude_code/api/request_ids.py @@ -0,0 +1,98 @@ +"""Ingress-owned HTTP request correlation.""" + +import uuid + +from fastapi import Request, Response +from loguru import logger +from starlette.datastructures import Headers, MutableHeaders +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from free_claude_code.core.trace import extract_claude_session_id_from_headers + +REQUEST_ID_HEADER = "request-id" +OPENAI_REQUEST_ID_HEADER = "x-request-id" +_REQUEST_ID_STATE_ATTRIBUTE = "fcc_request_id" +_OPENAI_REQUEST_ID_PATHS = frozenset({"/v1/responses", "/v1/models"}) + + +class RequestCorrelationMiddleware: + """Own one request id and logging context for the full ASGI response.""" + + def __init__(self, app: ASGIApp) -> None: + self._app = app + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + if scope["type"] != "http": + await self._app(scope, receive, send) + return + + request_id = new_request_id() + state = scope.setdefault("state", {}) + state[_REQUEST_ID_STATE_ATTRIBUTE] = request_id + method = scope.get("method", "") + path = scope.get("path", "") + request_headers = Headers(scope=scope) + claude_sid = extract_claude_session_id_from_headers(request_headers) + + async def send_with_correlation(message: Message) -> None: + if message["type"] == "http.response.start": + message = dict(message) + raw_headers = list(message.get("headers", ())) + _set_request_id_headers( + MutableHeaders(raw=raw_headers), + request_id=request_id, + path=path, + ) + message["headers"] = raw_headers + await send(message) + + with logger.contextualize( + http_method=method, + http_path=path, + claude_session_id=claude_sid, + request_id=request_id, + ): + await self._app(scope, receive, send_with_correlation) + + +def new_request_id() -> str: + """Return a new opaque FCC request identifier.""" + return f"req_{uuid.uuid4().hex}" + + +def set_request_id(request: Request, request_id: str) -> None: + """Attach the ingress correlation identifier to request state.""" + setattr(request.state, _REQUEST_ID_STATE_ATTRIBUTE, request_id) + + +def get_request_id(request: Request) -> str: + """Return the ingress correlation identifier, creating a fallback if needed.""" + request_id = getattr(request.state, _REQUEST_ID_STATE_ATTRIBUTE, None) + if isinstance(request_id, str) and request_id: + return request_id + request_id = new_request_id() + set_request_id(request, request_id) + return request_id + + +def attach_request_id_headers( + response: Response, *, request_id: str, path: str +) -> None: + """Attach correlation when an outer server-error boundary bypasses middleware.""" + _set_request_id_headers(response.headers, request_id=request_id, path=path) + + +def _set_request_id_headers( + headers: MutableHeaders, + *, + request_id: str, + path: str, +) -> None: + headers[REQUEST_ID_HEADER] = request_id + if path in _OPENAI_REQUEST_ID_PATHS: + headers[OPENAI_REQUEST_ID_HEADER] = request_id diff --git a/src/free_claude_code/api/response_streams.py b/src/free_claude_code/api/response_streams.py new file mode 100644 index 0000000..a66833e --- /dev/null +++ b/src/free_claude_code/api/response_streams.py @@ -0,0 +1,386 @@ +"""FastAPI streaming response wrappers for public API wire formats.""" + +import asyncio +from collections.abc import ( + AsyncIterator, + Awaitable, + Callable, + Mapping, +) +from typing import Any, Literal + +from fastapi.responses import JSONResponse, Response, StreamingResponse +from starlette.background import BackgroundTask +from starlette.responses import ContentStream +from starlette.types import Receive, Scope, Send + +from free_claude_code.core.anthropic import anthropic_error_type_for_failure +from free_claude_code.core.anthropic.streaming import ( + ANTHROPIC_SSE_RESPONSE_HEADERS, + anthropic_terminal_error_frame, + anthropic_terminal_failure_frame, +) +from free_claude_code.core.async_iterators import try_close_async_iterator +from free_claude_code.core.diagnostics import safe_exception_message +from free_claude_code.core.failures import find_execution_failure +from free_claude_code.core.trace import close_stream_input, trace_event + +TERMINAL_EXECUTION_ERROR_HEADERS = {"x-should-retry": "false"} + +PreStartErrorResponse = Callable[[BaseException], Response] +TerminalFrameEmitter = Callable[[BaseException], str] +TerminalFailureObserver = Callable[[BaseException], None] +ReleaseResponseResource = Callable[[], Awaitable[None]] +WireApi = Literal["messages", "responses"] + + +class EmptyStreamError(RuntimeError): + """Raised when a public stream ends before emitting any protocol chunk.""" + + +class ManagedStreamingResponse(StreamingResponse): + """Own body closure and one response-scoped runtime release callback.""" + + def __init__( + self, + content: ContentStream, + status_code: int = 200, + headers: Mapping[str, str] | None = None, + media_type: str | None = None, + background: BackgroundTask | None = None, + ) -> None: + super().__init__( + content, + status_code=status_code, + headers=headers, + media_type=media_type, + background=background, + ) + self._release: ReleaseResponseResource | None = None + self._cleanup_task: asyncio.Task[None] | None = None + + def bind_release(self, release: ReleaseResponseResource) -> None: + """Bind the resource retained for this response before ASGI execution.""" + if self._release is not None: + raise RuntimeError("A response resource release is already bound.") + if self._cleanup_task is not None: + raise RuntimeError("Cannot bind a resource after response cleanup started.") + self._release = release + + async def aclose(self) -> None: + """Close the body and release its runtime resource exactly once.""" + await self._close(preserved_error=None) + + async def _close(self, *, preserved_error: BaseException | None) -> None: + task = self._cleanup_task + if task is None: + task = asyncio.create_task( + self._cleanup(preserved_error=preserved_error), + name="fcc-api-response-cleanup", + ) + self._cleanup_task = task + await _wait_for_cleanup(task) + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> None: + preserved_error: BaseException | None = None + try: + await super().__call__(scope, receive, send) + except BaseException as exc: + preserved_error = exc + raise + finally: + await self._close(preserved_error=preserved_error) + + async def _cleanup(self, *, preserved_error: BaseException | None) -> None: + try: + await close_stream_input( + self.body_iterator, + owner="ManagedStreamingResponse", + source="api", + preserved_error=preserved_error, + ) + except Exception as exc: + _trace_response_cleanup_failure("close_body", exc) + + release = self._release + if release is None: + return + try: + await release() + except Exception as exc: + _trace_response_cleanup_failure("release_resource", exc) + + +async def _wait_for_cleanup(task: asyncio.Task[None]) -> None: + """Wait through repeated caller cancellation, then restore cancellation.""" + cancellation: asyncio.CancelledError | None = None + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError as exc: + cancellation = exc + + # Ordinary defensive failures are trace-only; cancellation remains control flow. + try: + task.result() + except asyncio.CancelledError: + if cancellation is not None: + raise cancellation from None + raise + except Exception as exc: + _trace_response_cleanup_failure("cleanup_task", exc) + + if cancellation is not None: + raise cancellation + + +def _trace_response_cleanup_failure(operation: str, exc: BaseException) -> None: + trace_event( + stage="egress", + event="free_claude_code.api.response.cleanup_failed", + source="api", + operation=operation, + exc_type=type(exc).__name__, + ) + + +async def bind_response_lifetime( + response: object, + release: ReleaseResponseResource, +) -> object: + """Retain a runtime resource until a response body is fully consumed.""" + if isinstance(response, ManagedStreamingResponse): + response.bind_release(release) + return response + if isinstance(response, StreamingResponse): + error = TypeError("Streaming API responses must use ManagedStreamingResponse.") + try: + await close_stream_input( + response.body_iterator, + owner="bind_response_lifetime", + source="api", + preserved_error=error, + ) + finally: + await release() + raise error + await release() + return response + + +def terminal_execution_error_response( + *, status_code: int, content: dict[str, Any] +) -> JSONResponse: + """Return a final provider-execution error without enabling client retries.""" + return JSONResponse( + status_code=status_code, + content=content, + headers=dict(TERMINAL_EXECUTION_ERROR_HEADERS), + ) + + +def trace_terminal_execution_error( + *, + wire_api: WireApi, + request_id: str, + status_code: int, + error_type: str, + error: BaseException | None = None, +) -> None: + """Record one correlated terminal-execution decision at the HTTP boundary.""" + fields: dict[str, object] = { + "stage": "egress", + "event": "free_claude_code.api.response.terminal_execution_error", + "source": "api", + "wire_api": wire_api, + "request_id": request_id, + "status_code": status_code, + "error_type": error_type, + "client_should_retry": False, + } + failure = find_execution_failure(error) if error is not None else None + if error is not None: + fields["exc_type"] = type(failure or error).__name__ + if failure is not None: + fields["failure_kind"] = failure.kind.value + fields["provider_retryable"] = failure.retryable + trace_event(**fields) + + +async def _first_chunk_streaming_response( + body: AsyncIterator[str], + *, + headers: Mapping[str, str], + pre_start_error_response: PreStartErrorResponse, + terminal_frame: TerminalFrameEmitter | None, + terminal_failure_observer: TerminalFailureObserver | None, +) -> Response: + try: + first_chunk = await anext(body) + except StopAsyncIteration: + error = EmptyStreamError("Stream ended before emitting a response.") + await _close_pre_start_body(body, preserved_error=error) + return pre_start_error_response(error) + except GeneratorExit as exc: + await _close_pre_start_body(body, preserved_error=exc) + raise + except asyncio.CancelledError as exc: + await _close_pre_start_body(body, preserved_error=exc) + raise + except BaseExceptionGroup as exc: + await _close_pre_start_body(body, preserved_error=exc) + return pre_start_error_response(exc) + except Exception as exc: + await _close_pre_start_body(body, preserved_error=exc) + return pre_start_error_response(exc) + + return ManagedStreamingResponse( + _PrefetchedStream( + first_chunk, + body, + terminal_frame=terminal_frame, + terminal_failure_observer=terminal_failure_observer, + ), + media_type="text/event-stream", + headers=dict(headers), + ) + + +async def _close_pre_start_body( + body: AsyncIterator[str], + *, + preserved_error: BaseException, +) -> None: + task = asyncio.create_task( + close_stream_input( + body, + owner="first_chunk_streaming_response", + source="api", + preserved_error=preserved_error, + ), + name="fcc-api-pre-start-stream-cleanup", + ) + await _wait_for_cleanup(task) + + +class _PrefetchedStream(AsyncIterator[str]): + """Replay one prefetched frame while retaining ownership of the tail.""" + + def __init__( + self, + first_chunk: str, + body: AsyncIterator[str], + *, + terminal_frame: TerminalFrameEmitter | None, + terminal_failure_observer: TerminalFailureObserver | None, + ) -> None: + self._first_chunk: str | None = first_chunk + self._body = body + self._terminal_frame = terminal_frame + self._terminal_failure_observer = terminal_failure_observer + self._done = False + self._closed = False + + def __aiter__(self) -> _PrefetchedStream: + return self + + async def __anext__(self) -> str: + if self._closed or self._done: + raise StopAsyncIteration + if self._first_chunk is not None: + first_chunk = self._first_chunk + self._first_chunk = None + return first_chunk + try: + return await anext(self._body) + except StopAsyncIteration: + self._done = True + raise + except BaseExceptionGroup as exc: + return self._terminal_chunk(find_execution_failure(exc) or exc) + except Exception as exc: + return self._terminal_chunk(exc) + + async def aclose(self) -> None: + if self._closed: + return + self._closed = True + self._done = True + close_error = await try_close_async_iterator(self._body) + if close_error is not None: + raise close_error + + def _terminal_chunk(self, exc: BaseException) -> str: + terminal_frame = self._terminal_frame + if terminal_frame is None: + raise exc + self._done = True + if self._terminal_failure_observer is not None: + self._terminal_failure_observer(exc) + return terminal_frame(exc) + + +async def anthropic_sse_streaming_response( + body: AsyncIterator[str], + *, + pre_start_error_response: PreStartErrorResponse, + request_id: str, +) -> Response: + """Return a streaming response for Anthropic-style SSE streams.""" + return await _first_chunk_streaming_response( + body, + headers=ANTHROPIC_SSE_RESPONSE_HEADERS, + pre_start_error_response=pre_start_error_response, + terminal_frame=_anthropic_terminal_frame, + terminal_failure_observer=lambda exc: _trace_anthropic_terminal_failure( + exc, + request_id=request_id, + ), + ) + + +def _anthropic_terminal_frame(exc: BaseException) -> str: + failure = find_execution_failure(exc) + if failure is not None: + return anthropic_terminal_failure_frame(failure) + return anthropic_terminal_error_frame(safe_exception_message(exc)) + + +def _trace_anthropic_terminal_failure( + exc: BaseException, + *, + request_id: str, +) -> None: + failure = find_execution_failure(exc) + trace_terminal_execution_error( + wire_api="messages", + request_id=request_id, + status_code=failure.status_code if failure is not None else 500, + error_type=( + anthropic_error_type_for_failure(failure) + if failure is not None + else "api_error" + ), + error=exc, + ) + + +async def openai_responses_sse_streaming_response( + body: AsyncIterator[str], + *, + headers: Mapping[str, str], + pre_start_error_response: PreStartErrorResponse, +) -> Response: + """Return a streaming response for OpenAI Responses-style SSE.""" + return await _first_chunk_streaming_response( + body, + headers=headers, + pre_start_error_response=pre_start_error_response, + terminal_frame=None, + terminal_failure_observer=None, + ) diff --git a/src/free_claude_code/api/routes.py b/src/free_claude_code/api/routes.py new file mode 100644 index 0000000..2ff1813 --- /dev/null +++ b/src/free_claude_code/api/routes.py @@ -0,0 +1,221 @@ +"""FastAPI route handlers.""" + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from loguru import logger + +from free_claude_code.application.errors import ApplicationError +from free_claude_code.application.ports import ProviderResolver, RequestRuntimeLease +from free_claude_code.config.model_refs import parse_provider_type +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import ( + MessagesRequest, + TokenCountRequest, + get_token_count, +) +from free_claude_code.core.openai_responses import OpenAIResponsesRequest +from free_claude_code.core.trace import trace_event + +from .dependencies import ( + get_services, + get_settings, + require_api_key, + resolve_provider, +) +from .handlers import MessagesHandler, ResponsesHandler, TokenCountHandler +from .model_catalog import ModelsListResponse, build_models_list_response +from .ports import ApiServices +from .request_errors import ordinary_application_error_response +from .request_ids import get_request_id +from .response_streams import bind_response_lifetime + +router = APIRouter() + + +def _provider_resolver(lease: RequestRuntimeLease) -> ProviderResolver: + return lambda provider_type: resolve_provider(provider_type, lease=lease) + + +async def _create_messages_response( + services: ApiServices, + request_data: MessagesRequest, + *, + request_id: str, +) -> object: + lease: RequestRuntimeLease | None = None + try: + lease = await services.requests.acquire() + handler = MessagesHandler( + lease.settings, + provider_resolver=_provider_resolver(lease), + token_counter=get_token_count, + generation_id=lease.generation_id, + ) + response = await handler.create(request_data, request_id=request_id) + except ApplicationError as exc: + if lease is not None: + await lease.release() + return ordinary_application_error_response( + exc, + wire_api="messages", + request_id=request_id, + ) + except BaseException: + if lease is not None: + await lease.release() + raise + assert lease is not None + return await bind_response_lifetime(response, lease.release) + + +async def _create_responses_response( + services: ApiServices, + request_data: OpenAIResponsesRequest, + *, + request_id: str, +) -> object: + lease: RequestRuntimeLease | None = None + try: + lease = await services.requests.acquire() + handler = ResponsesHandler( + lease.settings, + provider_resolver=_provider_resolver(lease), + generation_id=lease.generation_id, + ) + response = await handler.create(request_data, request_id=request_id) + except ApplicationError as exc: + if lease is not None: + await lease.release() + return ordinary_application_error_response( + exc, + wire_api="responses", + request_id=request_id, + ) + except BaseException: + if lease is not None: + await lease.release() + raise + assert lease is not None + return await bind_response_lifetime(response, lease.release) + + +def _probe_response(allow: str) -> Response: + return Response(status_code=204, headers={"Allow": allow}) + + +@router.post("/v1/messages") +async def create_message( + request: Request, + request_data: MessagesRequest, + services: ApiServices = Depends(get_services), + _auth=Depends(require_api_key), +): + """Create a message (streaming by default; stream=false gets aggregated JSON).""" + return await _create_messages_response( + services, + request_data, + request_id=get_request_id(request), + ) + + +@router.api_route("/v1/messages", methods=["HEAD", "OPTIONS"]) +async def probe_messages(_auth=Depends(require_api_key)): + return _probe_response("POST, HEAD, OPTIONS") + + +@router.post("/v1/responses") +async def create_response( + request: Request, + request_data: OpenAIResponsesRequest, + services: ApiServices = Depends(get_services), + _auth=Depends(require_api_key), +): + """Create an OpenAI Responses-compatible response through this proxy.""" + return await _create_responses_response( + services, + request_data, + request_id=get_request_id(request), + ) + + +@router.api_route("/v1/responses", methods=["HEAD", "OPTIONS"]) +async def probe_responses(_auth=Depends(require_api_key)): + return _probe_response("POST, HEAD, OPTIONS") + + +@router.post("/v1/messages/count_tokens") +async def count_tokens( + request: Request, + request_data: TokenCountRequest, + settings: Settings = Depends(get_settings), + _auth=Depends(require_api_key), +): + """Count tokens for a request.""" + handler = TokenCountHandler(settings, token_counter=get_token_count) + return handler.count(request_data, request_id=get_request_id(request)) + + +@router.api_route("/v1/messages/count_tokens", methods=["HEAD", "OPTIONS"]) +async def probe_count_tokens(_auth=Depends(require_api_key)): + return _probe_response("POST, HEAD, OPTIONS") + + +@router.get("/") +async def root( + settings: Settings = Depends(get_settings), + _auth=Depends(require_api_key), +): + return { + "status": "ok", + "provider": parse_provider_type(settings.model), + "model": settings.model, + } + + +@router.api_route("/", methods=["HEAD", "OPTIONS"]) +async def probe_root(): + return _probe_response("GET, HEAD, OPTIONS") + + +@router.get("/health") +async def health(): + return {"status": "healthy"} + + +@router.api_route("/health", methods=["HEAD", "OPTIONS"]) +async def probe_health(): + return _probe_response("GET, HEAD, OPTIONS") + + +@router.get("/v1/models", response_model=ModelsListResponse) +async def list_models( + services: ApiServices = Depends(get_services), + settings: Settings = Depends(get_settings), + _auth=Depends(require_api_key), +): + """List the model ids this proxy advertises to compatible clients.""" + trace_event(stage="ingress", event="free_claude_code.api.models.list", source="api") + return build_models_list_response(settings, services.requests) + + +@router.post("/stop") +async def stop_cli( + services: ApiServices = Depends(get_services), + _auth=Depends(require_api_key), +): + """Stop all CLI sessions and pending tasks.""" + result = await services.tasks.stop_all() + if result is None: + raise HTTPException(status_code=503, detail="Messaging system not initialized") + if result.source is not None: + logger.info("STOP_CLI: source={} cancelled_count=N/A", result.source) + return {"status": "stopped", "source": result.source} + + count = result.cancelled_count or 0 + trace_event( + stage="ingress", + event="free_claude_code.api.cli.stop_via_messaging_workflow", + source="api", + cancelled_nodes=count, + ) + logger.info("STOP_CLI: source=messaging_workflow cancelled_count={}", count) + return {"status": "stopped", "cancelled_count": count} diff --git a/src/free_claude_code/api/validation_log.py b/src/free_claude_code/api/validation_log.py new file mode 100644 index 0000000..87d812d --- /dev/null +++ b/src/free_claude_code/api/validation_log.py @@ -0,0 +1,46 @@ +"""Safe metadata summaries for HTTP 422 validation logging (no raw text content).""" + +from typing import Any + + +def summarize_request_validation_body( + body: Any, +) -> tuple[list[dict[str, Any]], list[str]]: + """Return message shape summary and tool name list for debug logs.""" + messages = body.get("messages") if isinstance(body, dict) else None + message_summary: list[dict[str, Any]] = [] + if isinstance(messages, list): + for msg in messages: + if not isinstance(msg, dict): + message_summary.append({"message_kind": type(msg).__name__}) + continue + content = msg.get("content") + item: dict[str, Any] = { + "role": msg.get("role"), + "content_kind": type(content).__name__, + } + if isinstance(content, list): + item["block_types"] = [ + block.get("type", "dict") + if isinstance(block, dict) + else type(block).__name__ + for block in content[:12] + ] + item["block_keys"] = [ + sorted(str(key) for key in block)[:12] + for block in content[:5] + if isinstance(block, dict) + ] + elif isinstance(content, str): + item["content_length"] = len(content) + message_summary.append(item) + + tool_names: list[str] = [] + if isinstance(body, dict) and isinstance(body.get("tools"), list): + tool_names = [ + str(tool.get("name", "")) + for tool in body["tools"] + if isinstance(tool, dict) + ] + + return message_summary, tool_names diff --git a/src/free_claude_code/api/web_tools/__init__.py b/src/free_claude_code/api/web_tools/__init__.py new file mode 100644 index 0000000..e0fd14c --- /dev/null +++ b/src/free_claude_code/api/web_tools/__init__.py @@ -0,0 +1,17 @@ +"""Submodules for Anthropic web server tool handling (search/fetch, egress, streaming).""" + +from .egress import ( + WebFetchEgressPolicy, + WebFetchEgressViolation, + enforce_web_fetch_egress, +) +from .request import is_web_server_tool_request +from .streaming import stream_web_server_tool_response + +__all__ = [ + "WebFetchEgressPolicy", + "WebFetchEgressViolation", + "enforce_web_fetch_egress", + "is_web_server_tool_request", + "stream_web_server_tool_response", +] diff --git a/src/free_claude_code/api/web_tools/constants.py b/src/free_claude_code/api/web_tools/constants.py new file mode 100644 index 0000000..db6546a --- /dev/null +++ b/src/free_claude_code/api/web_tools/constants.py @@ -0,0 +1,17 @@ +"""Limits and defaults for outbound web server tool HTTP.""" + +from free_claude_code.core.version import package_version + +_REQUEST_TIMEOUT_S = 20.0 +_MAX_SEARCH_RESULTS = 10 +_MAX_FETCH_CHARS = 24_000 +# Hard cap on raw bytes read from HTTP responses before decode / HTML parse (memory bound). +_MAX_WEB_FETCH_RESPONSE_BYTES = 2 * 1024 * 1024 +# Drain at most this many bytes from redirect responses before following Location. +_REDIRECT_RESPONSE_BODY_CAP_BYTES = 65_536 +_MAX_WEB_FETCH_REDIRECTS = 10 +_WEB_FETCH_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308}) + +_WEB_TOOL_HTTP_HEADERS = { + "User-Agent": f"Mozilla/5.0 compatible; free-claude-code/{package_version()}", +} diff --git a/src/free_claude_code/api/web_tools/egress.py b/src/free_claude_code/api/web_tools/egress.py new file mode 100644 index 0000000..1aec722 --- /dev/null +++ b/src/free_claude_code/api/web_tools/egress.py @@ -0,0 +1,105 @@ +"""Egress policy for user-controlled web_fetch URLs (SSRF guard).""" + +import ipaddress +import socket +from dataclasses import dataclass +from urllib.parse import urlparse + + +@dataclass(frozen=True, slots=True) +class WebFetchEgressPolicy: + """Egress rules for user-influenced web_fetch URLs.""" + + allow_private_network_targets: bool + allowed_schemes: frozenset[str] + + +class WebFetchEgressViolation(ValueError): + """Raised when a web_fetch URL is rejected by egress policy (SSRF guard).""" + + +def web_fetch_allowed_scheme_set(raw_schemes: str) -> frozenset[str]: + """Return normalized schemes allowed for web_fetch.""" + + return frozenset( + part.strip().lower() for part in raw_schemes.split(",") if part.strip() + ) + + +def _port_for_url(parsed) -> int: + if parsed.port is not None: + return parsed.port + return 443 if (parsed.scheme or "").lower() == "https" else 80 + + +def _stream_getaddrinfo_or_raise(host: str, port: int) -> list[tuple]: + try: + return socket.getaddrinfo( + host, port, type=socket.SOCK_STREAM, proto=socket.IPPROTO_TCP + ) + except OSError as exc: + raise WebFetchEgressViolation( + f"Could not resolve host {host!r}: {exc}" + ) from exc + + +def get_validated_stream_addrinfos_for_egress( + url: str, policy: WebFetchEgressPolicy +) -> list[tuple]: + """Resolve and validate a URL for web_fetch, returning getaddrinfo rows for pinning. + + Each HTTP connect pins to only these `getaddrinfo` results so a malicious DNS + server cannot rebind to a disallowed address between resolution and the TCP + connect (used by :func:`api.web_tools.outbound._run_web_fetch`). + """ + parsed = urlparse(url) + scheme = (parsed.scheme or "").lower() + if scheme not in policy.allowed_schemes: + raise WebFetchEgressViolation( + f"URL scheme {scheme!r} is not allowed for web_fetch" + ) + + host = parsed.hostname + if host is None or host == "": + raise WebFetchEgressViolation("web_fetch URL must include a host") + + port = _port_for_url(parsed) + + if policy.allow_private_network_targets: + return _stream_getaddrinfo_or_raise(host, port) + + host_lower = host.lower() + if host_lower == "localhost" or host_lower.endswith(".localhost"): + raise WebFetchEgressViolation("localhost targets are not allowed for web_fetch") + if host_lower.endswith(".local"): + raise WebFetchEgressViolation(".local hostnames are not allowed for web_fetch") + + try: + parsed_ip = ipaddress.ip_address(host) + except ValueError: + parsed_ip = None + + if parsed_ip is not None: + if not parsed_ip.is_global: + raise WebFetchEgressViolation( + f"Non-public IP host {host!r} is not allowed for web_fetch" + ) + return _stream_getaddrinfo_or_raise(host, port) + + infos = _stream_getaddrinfo_or_raise(host, port) + for *_, sockaddr in infos: + addr = sockaddr[0] + try: + resolved = ipaddress.ip_address(addr) + except ValueError: + continue + if not resolved.is_global: + raise WebFetchEgressViolation( + f"Host {host!r} resolves to a non-public address ({resolved})" + ) + return infos + + +def enforce_web_fetch_egress(url: str, policy: WebFetchEgressPolicy) -> None: + """Validate ``url`` (scheme, host, and resolved addresses) for web_fetch.""" + get_validated_stream_addrinfos_for_egress(url, policy) diff --git a/src/free_claude_code/api/web_tools/outbound.py b/src/free_claude_code/api/web_tools/outbound.py new file mode 100644 index 0000000..d043561 --- /dev/null +++ b/src/free_claude_code/api/web_tools/outbound.py @@ -0,0 +1,276 @@ +"""Outbound HTTP for web_search / web_fetch (client, body caps, logging).""" + +import asyncio +import socket +from collections.abc import AsyncIterator +from urllib.parse import urljoin, urlparse + +import aiohttp +import httpx +from aiohttp import ClientSession, ClientTimeout, TCPConnector +from aiohttp.abc import AbstractResolver, ResolveResult +from loguru import logger + +from . import constants +from .constants import ( + _MAX_FETCH_CHARS, + _MAX_SEARCH_RESULTS, + _REDIRECT_RESPONSE_BODY_CAP_BYTES, + _REQUEST_TIMEOUT_S, + _WEB_FETCH_REDIRECT_STATUSES, + _WEB_TOOL_HTTP_HEADERS, +) +from .egress import ( + WebFetchEgressPolicy, + WebFetchEgressViolation, + get_validated_stream_addrinfos_for_egress, +) +from .parsers import HTMLTextParser, SearchResultParser + + +def _safe_public_host_for_logs(url: str) -> str: + host = urlparse(url).hostname or "" + return host[:253] + + +def _log_web_tool_failure( + tool_name: str, + error: BaseException, + *, + fetch_url: str | None = None, +) -> None: + exc_type = type(error).__name__ + if isinstance(error, WebFetchEgressViolation): + host = _safe_public_host_for_logs(fetch_url) if fetch_url else "" + logger.warning( + "web_tool_egress_rejected tool={} exc_type={} host={!r}", + tool_name, + exc_type, + host, + ) + return + if tool_name == "web_fetch" and fetch_url: + logger.warning( + "web_tool_failure tool={} exc_type={} host={!r}", + tool_name, + exc_type, + _safe_public_host_for_logs(fetch_url), + ) + else: + logger.warning("web_tool_failure tool={} exc_type={}", tool_name, exc_type) + + +def _web_tool_client_error_summary( + tool_name: str, + error: BaseException, + *, + verbose: bool, +) -> str: + if verbose: + return f"{tool_name} failed: {type(error).__name__}" + return "Web tool request failed." + + +async def _iter_response_body_under_cap( + response: httpx.Response, max_bytes: int +) -> AsyncIterator[bytes]: + if max_bytes <= 0: + return + received = 0 + async for chunk in response.aiter_bytes(chunk_size=65_536): + if received >= max_bytes: + break + remaining = max_bytes - received + if len(chunk) <= remaining: + received += len(chunk) + yield chunk + if received >= max_bytes: + break + else: + yield chunk[:remaining] + break + + +async def _drain_response_body_capped(response: httpx.Response, max_bytes: int) -> None: + async for _ in _iter_response_body_under_cap(response, max_bytes): + pass + + +async def _read_response_body_capped(response: httpx.Response, max_bytes: int) -> bytes: + return b"".join( + [piece async for piece in _iter_response_body_under_cap(response, max_bytes)] + ) + + +_NUMERIC_RESOLVE_FLAGS = socket.AI_NUMERICHOST | socket.AI_NUMERICSERV +_NAME_RESOLVE_FLAGS = socket.NI_NUMERICHOST | socket.NI_NUMERICSERV + + +def getaddrinfo_rows_to_resolve_results( + host: str, addrinfos: list[tuple] +) -> list[ResolveResult]: + """Map :func:`socket.getaddrinfo` rows to aiohttp :class:`ResolveResult` (ThreadedResolver logic).""" + out: list[ResolveResult] = [] + for family, _type, proto, _canon, sockaddr in addrinfos: + if family == socket.AF_INET6: + if len(sockaddr) < 3: + continue + if sockaddr[3]: + resolved_host, port = socket.getnameinfo(sockaddr, _NAME_RESOLVE_FLAGS) + else: + resolved_host, port = sockaddr[:2] + else: + assert family == socket.AF_INET, family + resolved_host, port = sockaddr[0], sockaddr[1] + resolved_host = str(resolved_host) + port = int(port) + out.append( + ResolveResult( + hostname=host, + host=resolved_host, + port=int(port), + family=family, + proto=proto, + flags=_NUMERIC_RESOLVE_FLAGS, + ) + ) + return out + + +class _PinnedEgressStaticResolver(AbstractResolver): + """Return only pre-validated :class:`ResolveResult` for the outbound request.""" + + def __init__(self, results: list[ResolveResult]) -> None: + self._results = results + + async def resolve( + self, host: str, port: int = 0, family: int = socket.AF_INET + ) -> list[ResolveResult]: + return self._results + + async def close(self) -> None: # pragma: no cover - aiohttp contract + return + + +async def _read_aiohttp_body_capped( + response: aiohttp.ClientResponse, max_bytes: int +) -> bytes: + received = 0 + parts: list[bytes] = [] + async for chunk in response.content.iter_chunked(65_536): + if received >= max_bytes: + break + remaining = max_bytes - received + if len(chunk) <= remaining: + received += len(chunk) + parts.append(chunk) + else: + parts.append(chunk[:remaining]) + break + return b"".join(parts) + + +async def _drain_aiohttp_body_capped( + response: aiohttp.ClientResponse, max_bytes: int +) -> None: + if max_bytes <= 0: + return + received = 0 + async for chunk in response.content.iter_chunked(65_536): + received += len(chunk) + if received >= max_bytes: + break + + +async def _run_web_search(query: str) -> list[dict[str, str]]: + async with ( + httpx.AsyncClient( + timeout=_REQUEST_TIMEOUT_S, + follow_redirects=True, + headers=_WEB_TOOL_HTTP_HEADERS, + ) as client, + client.stream( + "GET", + "https://lite.duckduckgo.com/lite/", + params={"q": query}, + ) as response, + ): + response.raise_for_status() + body_bytes = await _read_response_body_capped( + response, constants._MAX_WEB_FETCH_RESPONSE_BYTES + ) + text = body_bytes.decode("utf-8", errors="replace") + parser = SearchResultParser() + parser.feed(text) + return parser.results[:_MAX_SEARCH_RESULTS] + + +async def _run_web_fetch(url: str, egress: WebFetchEgressPolicy) -> dict[str, str]: + """Fetch URL with manual redirects; each hop is DNS-pinned to validated addresses.""" + current_url = url + redirect_hops = 0 + timeout = ClientTimeout(total=_REQUEST_TIMEOUT_S) + + while True: + addr_infos = await asyncio.to_thread( + get_validated_stream_addrinfos_for_egress, current_url, egress + ) + host = urlparse(current_url).hostname or "" + results = getaddrinfo_rows_to_resolve_results(host, addr_infos) + resolver = _PinnedEgressStaticResolver(results) + connector = TCPConnector( + resolver=resolver, + force_close=True, + ) + try: + async with ( + ClientSession( + timeout=timeout, + headers=_WEB_TOOL_HTTP_HEADERS, + connector=connector, + ) as session, + session.get(current_url, allow_redirects=False) as response, + ): + if response.status in _WEB_FETCH_REDIRECT_STATUSES: + await _drain_aiohttp_body_capped( + response, _REDIRECT_RESPONSE_BODY_CAP_BYTES + ) + if redirect_hops >= constants._MAX_WEB_FETCH_REDIRECTS: + raise WebFetchEgressViolation( + "web_fetch exceeded maximum redirects " + f"({constants._MAX_WEB_FETCH_REDIRECTS})" + ) + location = response.headers.get("location") + if not location or not location.strip(): + raise WebFetchEgressViolation( + "web_fetch redirect response missing Location header" + ) + current_url = urljoin(str(response.url), location.strip()) + redirect_hops += 1 + continue + response.raise_for_status() + content_type = response.headers.get("content-type", "text/plain") + final_url = str(response.url) + encoding = response.get_encoding() or "utf-8" + body_bytes = await _read_aiohttp_body_capped( + response, constants._MAX_WEB_FETCH_RESPONSE_BYTES + ) + finally: + await connector.close() + + break + + text = body_bytes.decode(encoding, errors="replace") + title = final_url + data = text + if "html" in content_type.lower(): + parser = HTMLTextParser() + parser.feed(text) + title = parser.title or final_url + data = "\n".join(parser.text_parts) + return { + "url": final_url, + "title": title, + "media_type": "text/plain", + "data": data[:_MAX_FETCH_CHARS], + } diff --git a/src/free_claude_code/api/web_tools/parsers.py b/src/free_claude_code/api/web_tools/parsers.py new file mode 100644 index 0000000..797af7c --- /dev/null +++ b/src/free_claude_code/api/web_tools/parsers.py @@ -0,0 +1,102 @@ +"""HTML parsing for web_search / web_fetch.""" + +import html +import re +from html.parser import HTMLParser +from typing import Any +from urllib.parse import parse_qs, unquote, urlparse + + +class SearchResultParser(HTMLParser): + """DuckDuckGo lite HTML: extract result links and titles.""" + + def __init__(self) -> None: + super().__init__() + self.results: list[dict[str, str]] = [] + self._href: str | None = None + self._title_parts: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag != "a": + return + href = dict(attrs).get("href") + if not href or "uddg=" not in href: + return + parsed = urlparse(href) + query = parse_qs(parsed.query) + uddg = query.get("uddg", [""])[0] + if not uddg: + return + self._href = unquote(uddg) + self._title_parts = [] + + def handle_data(self, data: str) -> None: + if self._href is not None: + self._title_parts.append(data) + + def handle_endtag(self, tag: str) -> None: + if tag != "a" or self._href is None: + return + title = " ".join("".join(self._title_parts).split()) + if title and not any(result["url"] == self._href for result in self.results): + self.results.append({"title": html.unescape(title), "url": self._href}) + self._href = None + self._title_parts = [] + + +class HTMLTextParser(HTMLParser): + """Strip scripts/styles and collect visible text + title for fetch previews.""" + + def __init__(self) -> None: + super().__init__() + self.title = "" + self.text_parts: list[str] = [] + self._in_title = False + self._skip_depth = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag in {"script", "style", "noscript"}: + self._skip_depth += 1 + elif tag == "title": + self._in_title = True + + def handle_endtag(self, tag: str) -> None: + if tag in {"script", "style", "noscript"} and self._skip_depth: + self._skip_depth -= 1 + elif tag == "title": + self._in_title = False + + def handle_data(self, data: str) -> None: + text = " ".join(data.split()) + if not text: + return + if self._in_title: + self.title = f"{self.title} {text}".strip() + elif not self._skip_depth: + self.text_parts.append(text) + + +def content_text(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict): + parts.append(str(item.get("text", ""))) + else: + parts.append(str(getattr(item, "text", ""))) + return "\n".join(part for part in parts if part) + return str(content) + + +def extract_query(text: str) -> str: + match = re.search(r"query:\s*(.+)", text, flags=re.IGNORECASE | re.DOTALL) + if match: + return match.group(1).strip().strip("\"'") + return text.strip() + + +def extract_url(text: str) -> str: + match = re.search(r"https?://\S+", text) + return match.group(0).rstrip(").,]") if match else text.strip() diff --git a/src/free_claude_code/api/web_tools/request.py b/src/free_claude_code/api/web_tools/request.py new file mode 100644 index 0000000..7db4ad6 --- /dev/null +++ b/src/free_claude_code/api/web_tools/request.py @@ -0,0 +1,76 @@ +"""Detect forced Anthropic web server tool requests.""" + +from free_claude_code.core.anthropic import MessagesRequest, Tool + +from .parsers import content_text + + +def forced_tool_turn_text(request: MessagesRequest) -> str: + """Text for parsing forced server-tool inputs: latest user turn only (avoids stale history).""" + if not request.messages: + return "" + + for message in reversed(request.messages): + if message.role == "user": + return content_text(message.content) + return "" + + +def forced_server_tool_name(request: MessagesRequest) -> str | None: + """Return web_search or web_fetch only when tool_choice forces that server tool.""" + tc = request.tool_choice + if not isinstance(tc, dict): + return None + if tc.get("type") != "tool": + return None + name = tc.get("name") + if name in {"web_search", "web_fetch"}: + return str(name) + return None + + +def has_tool_named(request: MessagesRequest, name: str) -> bool: + return any(tool.name == name for tool in request.tools or []) + + +def is_web_server_tool_request(request: MessagesRequest) -> bool: + """True when the client forces a web server tool via tool_choice (not merely listed).""" + forced = forced_server_tool_name(request) + if forced is None: + return False + return has_tool_named(request, forced) + + +def is_anthropic_server_tool_definition(tool: Tool) -> bool: + """Whether ``tool`` refers to an Anthropic server tool (web_search / web_fetch family).""" + name = (tool.name or "").strip() + if name in ("web_search", "web_fetch"): + return True + typ = tool.type + if isinstance(typ, str): + return typ.startswith("web_search") or typ.startswith("web_fetch") + return False + + +def has_listed_anthropic_server_tools(request: MessagesRequest) -> bool: + """True when tools include web_search / web_fetch-style entries (listed, forced or not).""" + return any(is_anthropic_server_tool_definition(t) for t in (request.tools or [])) + + +def unsupported_server_tool_error( + request: MessagesRequest, *, web_tools_enabled: bool +) -> str | None: + """Return the user-facing error when the resolved provider cannot run server tools.""" + forced = forced_server_tool_name(request) + if forced and not web_tools_enabled: + return ( + f"tool_choice forces Anthropic server tool {forced!r}, but local web server tools are " + "disabled (ENABLE_WEB_SERVER_TOOLS=false). Enable them or remove the forced server tool." + ) + if not forced and has_listed_anthropic_server_tools(request): + return ( + "FCC cannot pass listed Anthropic server tools (web_search / web_fetch) " + "to OpenAI Chat upstreams. Set ENABLE_WEB_SERVER_TOOLS=true and force the " + "tool with tool_choice, or remove these tools from the request." + ) + return None diff --git a/src/free_claude_code/api/web_tools/streaming.py b/src/free_claude_code/api/web_tools/streaming.py new file mode 100644 index 0000000..3885e62 --- /dev/null +++ b/src/free_claude_code/api/web_tools/streaming.py @@ -0,0 +1,204 @@ +"""SSE streaming for local web_search / web_fetch server tool results.""" + +import uuid +from collections.abc import AsyncIterator +from datetime import UTC, datetime +from typing import Any + +from free_claude_code.core.anthropic import MessagesRequest +from free_claude_code.core.anthropic.server_tool_sse import ( + SERVER_TOOL_USE, + WEB_FETCH_TOOL_ERROR, + WEB_FETCH_TOOL_RESULT, + WEB_SEARCH_TOOL_RESULT, + WEB_SEARCH_TOOL_RESULT_ERROR, +) +from free_claude_code.core.anthropic.streaming import format_sse_event + +from . import outbound +from .constants import _MAX_FETCH_CHARS +from .egress import WebFetchEgressPolicy +from .parsers import extract_query, extract_url +from .request import ( + forced_server_tool_name, + forced_tool_turn_text, + has_tool_named, +) + + +def _search_summary(query: str, results: list[dict[str, str]]) -> str: + if not results: + return f"No web search results found for: {query}" + lines = [f"Search results for: {query}"] + for index, result in enumerate(results, start=1): + lines.append(f"{index}. {result['title']}\n{result['url']}") + return "\n\n".join(lines) + + +async def stream_web_server_tool_response( + request: MessagesRequest, + input_tokens: int, + *, + web_fetch_egress: WebFetchEgressPolicy, + verbose_client_errors: bool = False, +) -> AsyncIterator[str]: + """Stream a minimal Anthropic-shaped turn for forced `web_search` / `web_fetch` (local fallback). + + When `ENABLE_WEB_SERVER_TOOLS` is on, this is a proxy-side execution path — not a full + hosted Anthropic citation or encrypted-content pipeline. + """ + tool_name = forced_server_tool_name(request) + if tool_name is None or not has_tool_named(request, tool_name): + return + + text = forced_tool_turn_text(request) + message_id = f"msg_{uuid.uuid4()}" + tool_id = f"srvtoolu_{uuid.uuid4().hex}" + usage_key = ( + "web_search_requests" if tool_name == "web_search" else "web_fetch_requests" + ) + tool_input = ( + {"query": extract_query(text)} + if tool_name == "web_search" + else {"url": extract_url(text)} + ) + _result_block_for_tool = { + "web_search": WEB_SEARCH_TOOL_RESULT, + "web_fetch": WEB_FETCH_TOOL_RESULT, + } + _error_payload_type_for_tool = { + "web_search": WEB_SEARCH_TOOL_RESULT_ERROR, + "web_fetch": WEB_FETCH_TOOL_ERROR, + } + + yield format_sse_event( + "message_start", + { + "type": "message_start", + "message": { + "id": message_id, + "type": "message", + "role": "assistant", + "content": [], + "model": request.model, + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": input_tokens, "output_tokens": 1}, + }, + }, + ) + yield format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": SERVER_TOOL_USE, + "id": tool_id, + "name": tool_name, + "input": tool_input, + }, + }, + ) + yield format_sse_event( + "content_block_stop", {"type": "content_block_stop", "index": 0} + ) + + try: + if tool_name == "web_search": + query = str(tool_input["query"]) + results = await outbound._run_web_search(query) + result_content: Any = [ + { + "type": "web_search_result", + "title": result["title"], + "url": result["url"], + } + for result in results + ] + summary = _search_summary(query, results) + result_block_type = WEB_SEARCH_TOOL_RESULT + else: + fetched = await outbound._run_web_fetch( + str(tool_input["url"]), web_fetch_egress + ) + result_content = { + "type": "web_fetch_result", + "url": fetched["url"], + "content": { + "type": "document", + "source": { + "type": "text", + "media_type": fetched["media_type"], + "data": fetched["data"], + }, + "title": fetched["title"], + "citations": {"enabled": True}, + }, + "retrieved_at": datetime.now(UTC).isoformat(), + } + summary = fetched["data"][:_MAX_FETCH_CHARS] + result_block_type = WEB_FETCH_TOOL_RESULT + except Exception as error: + fetch_url = str(tool_input["url"]) if tool_name == "web_fetch" else None + outbound._log_web_tool_failure(tool_name, error, fetch_url=fetch_url) + result_block_type = _result_block_for_tool[tool_name] + result_content = { + "type": _error_payload_type_for_tool[tool_name], + "error_code": "unavailable", + } + summary = outbound._web_tool_client_error_summary( + tool_name, error, verbose=verbose_client_errors + ) + + output_tokens = max(1, len(summary) // 4) + + yield format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 1, + "content_block": { + "type": result_block_type, + "tool_use_id": tool_id, + "content": result_content, + }, + }, + ) + yield format_sse_event( + "content_block_stop", {"type": "content_block_stop", "index": 1} + ) + # Model-facing summary: stream as normal text deltas (CLI/transcript code reads `text_delta`, + # not eager `text` on `content_block_start`). + yield format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 2, + "content_block": {"type": "text", "text": ""}, + }, + ) + yield format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 2, + "delta": {"type": "text_delta", "text": summary}, + }, + ) + yield format_sse_event( + "content_block_stop", {"type": "content_block_stop", "index": 2} + ) + yield format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "server_tool_use": {usage_key: 1}, + }, + }, + ) + yield format_sse_event("message_stop", {"type": "message_stop"}) diff --git a/src/free_claude_code/application/__init__.py b/src/free_claude_code/application/__init__.py new file mode 100644 index 0000000..783af66 --- /dev/null +++ b/src/free_claude_code/application/__init__.py @@ -0,0 +1 @@ +"""Application use cases, values, and consumer-owned ports.""" diff --git a/src/free_claude_code/application/errors.py b/src/free_claude_code/application/errors.py new file mode 100644 index 0000000..f6f348f --- /dev/null +++ b/src/free_claude_code/application/errors.py @@ -0,0 +1,41 @@ +"""Deterministic application and readiness errors.""" + +from collections.abc import Iterable + +from free_claude_code.core.failures import FailureKind + + +class ApplicationError(Exception): + """Base for request/readiness failures, not finalized upstream failures.""" + + kind: FailureKind + status_code: int + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +class InvalidRequestError(ApplicationError): + """The accepted request cannot be executed deterministically.""" + + kind = FailureKind.INVALID_REQUEST + status_code = 400 + + +class UnknownProviderError(InvalidRequestError): + """The configured provider identifier is not registered.""" + + @classmethod + def for_provider( + cls, provider_id: str, supported_provider_ids: Iterable[str] + ) -> UnknownProviderError: + supported = "', '".join(supported_provider_ids) + return cls(f"Unknown provider_type: '{provider_id}'. Supported: '{supported}'") + + +class ApplicationUnavailableError(ApplicationError): + """The application cannot currently provide a request runtime.""" + + kind = FailureKind.UNAVAILABLE + status_code = 503 diff --git a/src/free_claude_code/application/execution.py b/src/free_claude_code/application/execution.py new file mode 100644 index 0000000..0e1c3f5 --- /dev/null +++ b/src/free_claude_code/application/execution.py @@ -0,0 +1,147 @@ +"""Provider execution shared by inbound API adapters.""" + +import sys +from collections.abc import AsyncIterator, Callable +from typing import Literal + +from loguru import logger + +from free_claude_code.core.anthropic import ( + Message, + SystemContent, + Tool, + anthropic_request_snapshot, + get_token_count, +) +from free_claude_code.core.trace import ( + close_stream_input, + trace_event, + traced_async_stream, +) + +from .ports import ProviderResolver +from .routing import RoutedMessagesRequest + +TokenCounter = Callable[ + [list[Message], str | list[SystemContent] | None, list[Tool] | None], + int, +] +WireApi = Literal["messages", "responses"] + + +class ProviderExecutor: + """Resolve a provider and execute one routed Anthropic Messages stream.""" + + def __init__( + self, + provider_resolver: ProviderResolver, + *, + token_counter: TokenCounter = get_token_count, + generation_id: int | None = None, + log_raw_payloads: bool = False, + ) -> None: + self._provider_resolver = provider_resolver + self._token_counter = token_counter + self._generation_id = generation_id + self._log_raw_payloads = log_raw_payloads + + def stream( + self, + routed: RoutedMessagesRequest, + *, + wire_api: WireApi, + raw_log_label: str, + raw_log_payload: object, + request_id: str, + ) -> AsyncIterator[str]: + """Preflight synchronously, then return the traced provider stream.""" + provider = self._provider_resolver(routed.resolved.provider_id) + provider.preflight_stream( + routed.request, + thinking_enabled=routed.resolved.thinking_enabled, + ) + + route_trace: dict[str, object] = { + "stage": "routing", + "event": "free_claude_code.api.route.resolved", + "source": "api", + "request_id": request_id, + "provider_id": routed.resolved.provider_id, + "provider_model": routed.resolved.provider_model, + "provider_model_ref": routed.resolved.provider_model_ref, + "gateway_model": routed.request.model, + "thinking_enabled": routed.resolved.thinking_enabled, + } + if wire_api == "responses": + route_trace["wire_api"] = "responses" + if self._generation_id is not None: + route_trace["generation_id"] = self._generation_id + trace_event(**route_trace) + + trace_event( + stage="ingress", + event=( + "free_claude_code.api.responses.request.received" + if wire_api == "responses" + else "free_claude_code.api.request.received" + ), + source="api", + message_count=len(routed.request.messages), + snapshot=anthropic_request_snapshot(routed.request), + request_id=request_id, + ) + + if self._log_raw_payloads: + logger.debug(f"{raw_log_label} [{{}}]: {{}}", request_id, raw_log_payload) + + input_tokens = self._token_counter( + routed.request.messages, + routed.request.system, + routed.request.tools, + ) + + async def provider_body() -> AsyncIterator[str]: + provider_stream: AsyncIterator[str] | None = None + try: + provider_stream = provider.stream_response( + routed.request, + input_tokens=input_tokens, + request_id=request_id, + thinking_enabled=routed.resolved.thinking_enabled, + ) + async for chunk in provider_stream: + yield chunk + finally: + if provider_stream is not None: + await close_stream_input( + provider_stream, + owner="provider_executor", + source="api", + preserved_error=sys.exception(), + ) + + stream_trace: dict[str, object] = { + "request_id": request_id, + "provider_id": routed.resolved.provider_id, + "gateway_model": routed.request.model, + } + if self._generation_id is not None: + stream_trace["generation_id"] = self._generation_id + + return traced_async_stream( + provider_body(), + stage="egress", + source="api", + complete_event=( + "free_claude_code.api.responses.stream_completed" + if wire_api == "responses" + else "free_claude_code.api.response.stream_completed" + ), + interrupted_event=( + "free_claude_code.api.responses.stream_interrupted" + if wire_api == "responses" + else "free_claude_code.api.response.stream_interrupted" + ), + chunk_event=None, + extra=stream_trace, + ) diff --git a/src/free_claude_code/application/model_metadata.py b/src/free_claude_code/application/model_metadata.py new file mode 100644 index 0000000..f9433e9 --- /dev/null +++ b/src/free_claude_code/application/model_metadata.py @@ -0,0 +1,11 @@ +"""Application-owned model metadata.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ProviderModelInfo: + """Provider model metadata used to shape the application model catalog.""" + + model_id: str + supports_thinking: bool | None = None diff --git a/src/free_claude_code/application/ports.py b/src/free_claude_code/application/ports.py new file mode 100644 index 0000000..0f83cee --- /dev/null +++ b/src/free_claude_code/application/ports.py @@ -0,0 +1,77 @@ +"""Typed capabilities consumed by application use cases.""" + +from collections.abc import AsyncIterator, Callable +from dataclasses import dataclass +from typing import Protocol + +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import MessagesRequest + +from .model_metadata import ProviderModelInfo + + +class ProviderPort(Protocol): + """Minimal provider capability required to execute one request.""" + + def preflight_stream( + self, + request: MessagesRequest, + *, + thinking_enabled: bool, + ) -> None: ... + + def stream_response( + self, + request: MessagesRequest, + *, + input_tokens: int, + request_id: str, + thinking_enabled: bool, + ) -> AsyncIterator[str]: ... + + +ProviderResolver = Callable[[str], ProviderPort] + + +class RequestRuntimeLease(Protocol): + """One provider generation retained for a complete API response.""" + + @property + def generation_id(self) -> int: ... + + @property + def settings(self) -> Settings: ... + + def is_provider_cached(self, provider_id: str) -> bool: ... + + def resolve_provider(self, provider_id: str) -> ProviderPort: ... + + async def release(self) -> None: ... + + +class RequestRuntimePort(Protocol): + """Provider generation and model metadata required by application requests.""" + + async def acquire(self) -> RequestRuntimeLease: ... + + def current_settings(self) -> Settings: ... + + def cached_model_supports_thinking( + self, provider_id: str, model_id: str + ) -> bool | None: ... + + def cached_prefixed_model_infos(self) -> tuple[ProviderModelInfo, ...]: ... + + +@dataclass(frozen=True, slots=True) +class StopResult: + """Implementation-neutral result retaining the existing ``/stop`` variants.""" + + cancelled_count: int | None = None + source: str | None = None + + +class TaskController(Protocol): + """Stop managed work without exposing messaging or CLI resources.""" + + async def stop_all(self) -> StopResult | None: ... diff --git a/src/free_claude_code/application/routing.py b/src/free_claude_code/application/routing.py new file mode 100644 index 0000000..e1267f5 --- /dev/null +++ b/src/free_claude_code/application/routing.py @@ -0,0 +1,157 @@ +"""Model routing for Claude-compatible requests.""" + +from dataclasses import dataclass + +from loguru import logger + +from free_claude_code.application.errors import UnknownProviderError +from free_claude_code.config.model_refs import parse_model_name, parse_provider_type +from free_claude_code.config.provider_catalog import ( + PROVIDER_CATALOG, + SUPPORTED_PROVIDER_IDS, +) +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import MessagesRequest, TokenCountRequest +from free_claude_code.core.gateway_model_ids import decode_gateway_model_id + + +@dataclass(frozen=True, slots=True) +class ResolvedModel: + original_model: str + provider_id: str + provider_model: str + provider_model_ref: str + thinking_enabled: bool + + +@dataclass(frozen=True, slots=True) +class RoutedMessagesRequest: + request: MessagesRequest + resolved: ResolvedModel + + +@dataclass(frozen=True, slots=True) +class RoutedTokenCountRequest: + request: TokenCountRequest + resolved: ResolvedModel + + +class ModelRouter: + """Resolve incoming Claude model names to configured provider/model pairs.""" + + def __init__(self, settings: Settings): + self._settings = settings + + def resolve(self, claude_model_name: str) -> ResolvedModel: + ( + direct_provider_id, + direct_provider_model, + force_thinking_enabled, + ) = self._direct_provider_model(claude_model_name) + if direct_provider_id is not None and direct_provider_model is not None: + thinking_enabled = ( + force_thinking_enabled + if force_thinking_enabled is not None + else self._resolve_thinking(direct_provider_model) + ) + logger.debug( + "MODEL DIRECT: '{}' -> provider='{}' model='{}' thinking={}", + claude_model_name, + direct_provider_id, + direct_provider_model, + thinking_enabled, + ) + return ResolvedModel( + original_model=claude_model_name, + provider_id=direct_provider_id, + provider_model=direct_provider_model, + provider_model_ref=claude_model_name, + thinking_enabled=thinking_enabled, + ) + + provider_model_ref = self._resolve_model_ref(claude_model_name) + thinking_enabled = self._resolve_thinking(claude_model_name) + provider_id = parse_provider_type(provider_model_ref) + self._validate_provider_id(provider_id) + provider_model = parse_model_name(provider_model_ref) + if provider_model != claude_model_name: + logger.debug( + "MODEL MAPPING: '{}' -> '{}'", claude_model_name, provider_model + ) + return ResolvedModel( + original_model=claude_model_name, + provider_id=provider_id, + provider_model=provider_model, + provider_model_ref=provider_model_ref, + thinking_enabled=thinking_enabled, + ) + + @staticmethod + def _validate_provider_id(provider_id: str) -> None: + if provider_id not in PROVIDER_CATALOG: + raise UnknownProviderError.for_provider(provider_id, PROVIDER_CATALOG) + + def _direct_provider_model( + self, model_name: str + ) -> tuple[str | None, str | None, bool | None]: + decoded = decode_gateway_model_id(model_name) + if decoded is not None: + if decoded.provider_id not in SUPPORTED_PROVIDER_IDS: + return None, None, None + return ( + decoded.provider_id, + decoded.provider_model, + decoded.force_thinking_enabled, + ) + + provider_id, separator, provider_model = model_name.partition("/") + if not separator: + return None, None, None + if provider_id not in SUPPORTED_PROVIDER_IDS: + return None, None, None + if not provider_model: + return None, None, None + return provider_id, provider_model, None + + def _resolve_model_ref(self, claude_model_name: str) -> str: + """Resolve a Claude model name to the configured provider/model ref.""" + + name_lower = claude_model_name.lower() + if "opus" in name_lower and self._settings.model_opus is not None: + return self._settings.model_opus + if "haiku" in name_lower and self._settings.model_haiku is not None: + return self._settings.model_haiku + if "sonnet" in name_lower and self._settings.model_sonnet is not None: + return self._settings.model_sonnet + return self._settings.model + + def _resolve_thinking(self, claude_model_name: str) -> bool: + """Resolve whether thinking is enabled for an incoming Claude model name.""" + + name_lower = claude_model_name.lower() + if "opus" in name_lower and self._settings.enable_opus_thinking is not None: + return self._settings.enable_opus_thinking + if "haiku" in name_lower and self._settings.enable_haiku_thinking is not None: + return self._settings.enable_haiku_thinking + if "sonnet" in name_lower and self._settings.enable_sonnet_thinking is not None: + return self._settings.enable_sonnet_thinking + return self._settings.enable_model_thinking + + def resolve_messages_request( + self, request: MessagesRequest + ) -> RoutedMessagesRequest: + """Return an internal routed request context.""" + resolved = self.resolve(request.model) + routed = request.model_copy(deep=True) + routed.model = resolved.provider_model + return RoutedMessagesRequest(request=routed, resolved=resolved) + + def resolve_token_count_request( + self, request: TokenCountRequest + ) -> RoutedTokenCountRequest: + """Return an internal token-count request context.""" + resolved = self.resolve(request.model) + routed = request.model_copy( + update={"model": resolved.provider_model}, deep=True + ) + return RoutedTokenCountRequest(request=routed, resolved=resolved) diff --git a/src/free_claude_code/cli/__init__.py b/src/free_claude_code/cli/__init__.py new file mode 100644 index 0000000..aeec27b --- /dev/null +++ b/src/free_claude_code/cli/__init__.py @@ -0,0 +1,5 @@ +"""CLI integration for installed launchers and managed Claude Code.""" + +from .managed import ManagedClaudeSession, ManagedClaudeSessionManager + +__all__ = ["ManagedClaudeSession", "ManagedClaudeSessionManager"] diff --git a/src/free_claude_code/cli/claude_env.py b/src/free_claude_code/cli/claude_env.py new file mode 100644 index 0000000..42c942b --- /dev/null +++ b/src/free_claude_code/cli/claude_env.py @@ -0,0 +1,32 @@ +"""Shared Claude Code environment policy for FCC client surfaces.""" + +from collections.abc import Mapping + +from free_claude_code.cli.proxy_auth import proxy_auth_token + +CLAUDE_CODE_AUTO_COMPACT_WINDOW = "190000" +CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1" +CLAUDE_BINARY_NAME = "claude" + + +def build_claude_proxy_env( + *, + proxy_root_url: str, + auth_token: str, + base_env: Mapping[str, str], +) -> dict[str, str]: + """Return the canonical environment for Claude Code proxy sessions.""" + + env = { + key: value + for key, value in base_env.items() + if not key.startswith("ANTHROPIC_") + } + env["ANTHROPIC_BASE_URL"] = proxy_root_url + env["ANTHROPIC_AUTH_TOKEN"] = proxy_auth_token(auth_token) + env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1" + env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = CLAUDE_CODE_AUTO_COMPACT_WINDOW + env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = ( + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC + ) + return env diff --git a/src/free_claude_code/cli/entrypoints.py b/src/free_claude_code/cli/entrypoints.py new file mode 100644 index 0000000..fe4a028 --- /dev/null +++ b/src/free_claude_code/cli/entrypoints.py @@ -0,0 +1,176 @@ +"""CLI entry points for the installed package.""" + +import os +import shutil +import sys +import threading +import time +import webbrowser +from collections.abc import Sequence +from pathlib import Path + +import uvicorn + +from free_claude_code.cli.launchers.common import preflight_proxy +from free_claude_code.cli.process_registry import ( + kill_all_best_effort, +) +from free_claude_code.config.env_migrations import ( + explicit_env_file_huggingface_warning, + migrate_owned_env_files, +) +from free_claude_code.config.env_template import load_env_template +from free_claude_code.config.paths import ( + config_dir_path, + legacy_env_paths, + managed_env_path, +) +from free_claude_code.config.server_urls import local_admin_url, local_proxy_root_url +from free_claude_code.config.settings import Settings, get_settings +from free_claude_code.core.version import package_version +from free_claude_code.runtime.bootstrap import build_asgi_app + +SERVER_GRACEFUL_SHUTDOWN_SECONDS = 5 + + +def serve(argv: Sequence[str] | None = None) -> None: + """Start the FastAPI server (registered as `fcc-server` script).""" + if _print_version_if_requested(argv): + return + opened_admin_browser = False + try: + try: + while True: + _migrate_legacy_env_if_missing() + _migrate_config_env_keys() + settings = get_settings() + if not _run_supervised_server( + settings, open_admin_browser=not opened_admin_browser + ): + return + opened_admin_browser = True + get_settings.cache_clear() + except KeyboardInterrupt: + return + finally: + kill_all_best_effort() + + +def _admin_browser_open_enabled() -> bool: + """Whether to open /admin when the server becomes reachable (FCC_OPEN_BROWSER).""" + + raw = os.environ.get("FCC_OPEN_BROWSER", "true").strip().lower() + return raw not in {"", "0", "false", "no"} + + +def _schedule_open_admin_browser(settings: Settings) -> None: + """After /health succeeds, open the admin UI in the default browser (daemon thread).""" + + if not _admin_browser_open_enabled(): + return + + admin_url = local_admin_url(settings) + proxy_root_url = local_proxy_root_url(settings) + + def open_when_ready() -> None: + deadline = time.monotonic() + 30.0 + while time.monotonic() < deadline: + if preflight_proxy(proxy_root_url) is None: + webbrowser.open(admin_url) + return + time.sleep(0.15) + + threading.Thread( + target=open_when_ready, name="fcc-open-admin-browser", daemon=True + ).start() + + +def _run_supervised_server(settings: Settings, *, open_admin_browser: bool) -> bool: + """Run once; restart only after the old ownership graph fully closes.""" + + restart_requested = False + server_holder: dict[str, uvicorn.Server] = {} + + def request_restart() -> None: + nonlocal restart_requested + restart_requested = True + if server := server_holder.get("server"): + server.should_exit = True + + asgi_app = build_asgi_app(settings, restart_callback=request_restart) + config = uvicorn.Config( + asgi_app, + host=settings.host, + port=settings.port, + log_level="debug", + timeout_graceful_shutdown=SERVER_GRACEFUL_SHUTDOWN_SECONDS, + ) + server = uvicorn.Server(config) + server_holder["server"] = server + if open_admin_browser: + _schedule_open_admin_browser(settings) + server.run() + return restart_requested and asgi_app.runtime.is_closed + + +def init(argv: Sequence[str] | None = None) -> None: + """Scaffold config at ~/.fcc/.env (registered as `fcc-init`).""" + if _print_version_if_requested(argv): + return + config_dir = config_dir_path() + env_file = managed_env_path() + + migrated_from = _migrate_legacy_env_if_missing() + _migrate_config_env_keys() + if migrated_from is not None: + print(f"Config migrated from {migrated_from} to {env_file}") + print( + "Edit it to set your API keys and model preferences, then run: fcc-server" + ) + return + + if env_file.exists(): + print(f"Config already exists at {env_file}") + print("Delete it first if you want to reset to defaults.") + return + + config_dir.mkdir(parents=True, exist_ok=True) + template = load_env_template() + env_file.write_text(template, encoding="utf-8") + print(f"Config created at {env_file}") + print("Edit it to set your API keys and model preferences, then run: fcc-server") + + +def _print_version_if_requested(argv: Sequence[str] | None) -> bool: + args = sys.argv[1:] if argv is None else argv + if "--version" not in args: + return False + print(f"free-claude-code {package_version()}") + return True + + +def _migrate_legacy_env_if_missing() -> Path | None: + """Copy a legacy user env into the managed config path when absent.""" + + env_file = managed_env_path() + if env_file.exists(): + return None + + # TODO: Remove after the ~/.fcc/.env migration has had a release cycle. + for legacy_env in legacy_env_paths(): + if not legacy_env.is_file(): + continue + env_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(legacy_env, env_file) + return legacy_env + + return None + + +def _migrate_config_env_keys() -> tuple[Path, ...]: + """Apply dotenv key migrations before Settings loads config.""" + + migrated = migrate_owned_env_files() + if warning := explicit_env_file_huggingface_warning(os.environ): + print(warning, file=sys.stderr) + return migrated diff --git a/src/free_claude_code/cli/launchers/__init__.py b/src/free_claude_code/cli/launchers/__init__.py new file mode 100644 index 0000000..a78dba7 --- /dev/null +++ b/src/free_claude_code/cli/launchers/__init__.py @@ -0,0 +1 @@ +"""Installed FCC client CLI launchers.""" diff --git a/src/free_claude_code/cli/launchers/claude.py b/src/free_claude_code/cli/launchers/claude.py new file mode 100644 index 0000000..8dbc988 --- /dev/null +++ b/src/free_claude_code/cli/launchers/claude.py @@ -0,0 +1,64 @@ +"""Installed `fcc-claude` launcher.""" + +import os +import sys +from collections.abc import Sequence + +from free_claude_code.cli.claude_env import ( + CLAUDE_BINARY_NAME, + build_claude_proxy_env, +) +from free_claude_code.config.server_urls import local_proxy_root_url +from free_claude_code.config.settings import get_settings + +from .common import preflight_proxy, resolve_client_binary, run_client_process + +_DISPLAY_NAME = "Claude Code" +_INSTALL_HINT = "Install Claude Code with: npm install -g @anthropic-ai/claude-code" + + +def launch(argv: Sequence[str] | None = None) -> None: + """Launch Claude Code with Free Claude Code proxy environment variables.""" + + settings = get_settings() + proxy_root_url = local_proxy_root_url(settings) + if error := preflight_proxy(proxy_root_url): + print( + f"Free Claude Code proxy is not reachable at {proxy_root_url}: {error}", + file=sys.stderr, + ) + print("Start it in another terminal with: fcc-server", file=sys.stderr) + raise SystemExit(1) + + binary_name = claude_binary_name() + binary_path = resolve_client_binary( + binary_name=binary_name, + display_name=_DISPLAY_NAME, + install_hint=_INSTALL_HINT, + ) + args = list(sys.argv[1:] if argv is None else argv) + run_client_process( + command=build_claude_launcher_command(binary_path=binary_path, argv=args), + env=build_claude_proxy_env( + proxy_root_url=proxy_root_url, + auth_token=settings.anthropic_auth_token, + base_env=os.environ, + ), + binary_name=binary_name, + display_name=_DISPLAY_NAME, + install_hint=_INSTALL_HINT, + ) + + +def claude_binary_name() -> str: + """Return the Claude Code binary name.""" + + return CLAUDE_BINARY_NAME + + +def build_claude_launcher_command( + *, binary_path: str, argv: Sequence[str] +) -> list[str]: + """Return the Claude wrapper command without changing user arguments.""" + + return [binary_path, *argv] diff --git a/src/free_claude_code/cli/launchers/codex.py b/src/free_claude_code/cli/launchers/codex.py new file mode 100644 index 0000000..3d195d4 --- /dev/null +++ b/src/free_claude_code/cli/launchers/codex.py @@ -0,0 +1,208 @@ +"""Installed `fcc-codex` launcher.""" + +import json +import os +import sys +from collections.abc import Mapping, Sequence +from urllib.request import Request, urlopen + +from free_claude_code.cli.proxy_auth import proxy_auth_token +from free_claude_code.config.paths import codex_model_catalog_path +from free_claude_code.config.server_urls import local_proxy_root_url +from free_claude_code.config.settings import Settings, get_settings + +from .codex_model_catalog import build_codex_model_catalog, write_codex_model_catalog +from .common import ( + PROXY_PREFLIGHT_TIMEOUT_SECONDS, + preflight_proxy, + resolve_client_binary, + run_client_process, +) + +_CODEX_AUTH_ENV_KEY = "FCC_CODEX_API_KEY" +_DISPLAY_NAME = "Codex CLI" +_DEFAULT_BINARY = "codex" +_INSTALL_HINT = "Install Codex with: npm install -g @openai/codex" +# Preserve CODEX_HOME: it owns durable user configuration, not parent-task identity. +_STRIPPED_CODEX_ENV_KEYS = frozenset( + { + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_API_BASE", + "OPENAI_ORG_ID", + "OPENAI_ORGANIZATION", + "CODEX_API_KEY", + "CODEX_INTERNAL_ORIGINATOR_OVERRIDE", + "CODEX_PERMISSION_PROFILE", + "CODEX_SHELL", + "CODEX_THREAD_ID", + _CODEX_AUTH_ENV_KEY, + } +) + + +def launch(argv: Sequence[str] | None = None) -> None: + """Launch Codex CLI with Free Claude Code proxy configuration.""" + + settings = get_settings() + proxy_root_url = local_proxy_root_url(settings) + if error := preflight_proxy(proxy_root_url): + print( + f"Free Claude Code proxy is not reachable at {proxy_root_url}: {error}", + file=sys.stderr, + ) + print("Start it in another terminal with: fcc-server", file=sys.stderr) + raise SystemExit(1) + + binary_name = codex_binary_name() + binary_path = resolve_client_binary( + binary_name=binary_name, + display_name=_DISPLAY_NAME, + install_hint=_INSTALL_HINT, + ) + catalog_args = codex_model_catalog_config_args(proxy_root_url, settings) + args = list(sys.argv[1:] if argv is None else argv) + run_client_process( + command=build_codex_launcher_command( + binary_path=binary_path, + argv=args, + settings=settings, + proxy_root_url=proxy_root_url, + catalog_config_args=catalog_args, + ), + env=build_codex_launcher_env( + auth_token=settings.anthropic_auth_token, + base_env=os.environ, + ), + binary_name=binary_name, + display_name=_DISPLAY_NAME, + install_hint=_INSTALL_HINT, + ) + + +def codex_binary_name() -> str: + """Return the Codex CLI binary name.""" + + return _DEFAULT_BINARY + + +def build_codex_launcher_command( + *, + binary_path: str, + argv: Sequence[str], + settings: Settings, + proxy_root_url: str, + catalog_config_args: Sequence[str] = (), +) -> list[str]: + """Return a Codex command with ephemeral FCC provider config.""" + + return [ + binary_path, + *catalog_config_args, + *codex_config_args( + api_url=_ensure_v1_url(proxy_root_url), + model=getattr(settings, "model", None), + ), + *argv, + ] + + +def build_codex_launcher_env( + *, + auth_token: str, + base_env: Mapping[str, str], +) -> dict[str, str]: + """Return a Codex environment that targets the local proxy provider.""" + + env = { + key: value + for key, value in base_env.items() + if key not in _STRIPPED_CODEX_ENV_KEYS and not key.startswith("OPENAI_") + } + env[_CODEX_AUTH_ENV_KEY] = proxy_auth_token(auth_token) + return env + + +def codex_model_catalog_config_args( + proxy_root_url: str, settings: Settings +) -> list[str]: + """Prepare the generated Codex model catalog and return its config args.""" + + try: + models_response = fetch_proxy_models_response( + proxy_root_url, settings.anthropic_auth_token + ) + catalog = build_codex_model_catalog(models_response) + models = catalog.get("models") + if not isinstance(models, list) or not models: + print( + "Free Claude Code warning: Codex model catalog is empty; " + "launching without model picker catalog.", + file=sys.stderr, + ) + return [] + catalog_path = codex_model_catalog_path() + write_codex_model_catalog(catalog_path, catalog) + except Exception as exc: + print( + "Free Claude Code warning: could not prepare Codex model catalog " + f"({exc}); launching without model picker catalog.", + file=sys.stderr, + ) + return [] + + return build_model_catalog_config_args(str(catalog_path)) + + +def fetch_proxy_models_response( + proxy_root_url: str, auth_token: str +) -> dict[str, object]: + """Fetch the local proxy `/v1/models` response for Codex catalog generation.""" + + url = f"{proxy_root_url.rstrip('/')}/v1/models" + headers: dict[str, str] = {} + if token := auth_token.strip(): + headers["X-API-Key"] = token + + request = Request(url, headers=headers, method="GET") + with urlopen(request, timeout=PROXY_PREFLIGHT_TIMEOUT_SECONDS) as response: + payload = json.loads(response.read().decode("utf-8")) + + if not isinstance(payload, dict): + raise ValueError("model list response was not a JSON object") + return payload + + +def build_model_catalog_config_args(catalog_path: str) -> list[str]: + """Return Codex config args for a generated model catalog.""" + + return ["-c", _toml_assignment("model_catalog_json", catalog_path)] + + +def codex_config_args(*, api_url: str, model: str | None = None) -> list[str]: + """Return Codex `-c` assignments for the ephemeral FCC provider.""" + + args = [ + "-c", + _toml_assignment("model_provider", "fcc"), + "-c", + _toml_assignment("model_providers.fcc.name", "Free Claude Code"), + "-c", + _toml_assignment("model_providers.fcc.base_url", _ensure_v1_url(api_url)), + "-c", + _toml_assignment("model_providers.fcc.env_key", _CODEX_AUTH_ENV_KEY), + "-c", + _toml_assignment("model_providers.fcc.wire_api", "responses"), + ] + if model: + args.extend(["-c", _toml_assignment("model", model)]) + return args + + +def _ensure_v1_url(url: str) -> str: + stripped = url.rstrip("/") + return stripped if stripped.endswith("/v1") else f"{stripped}/v1" + + +def _toml_assignment(key: str, value: str) -> str: + return f"{key}={json.dumps(value)}" diff --git a/src/free_claude_code/cli/launchers/codex_model_catalog.py b/src/free_claude_code/cli/launchers/codex_model_catalog.py new file mode 100644 index 0000000..32504d8 --- /dev/null +++ b/src/free_claude_code/cli/launchers/codex_model_catalog.py @@ -0,0 +1,184 @@ +"""Build Codex model catalogs from the FCC model-list route.""" + +import json +import uuid +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from free_claude_code.config.provider_catalog import SUPPORTED_PROVIDER_IDS +from free_claude_code.core.gateway_model_ids import ( + GATEWAY_MODEL_ID_PREFIX, + NO_THINKING_GATEWAY_MODEL_ID_PREFIX, +) + +SUPPORTED_REASONING_LEVELS = [ + {"effort": "low", "description": "Fast responses with lighter reasoning"}, + { + "effort": "medium", + "description": "Balances speed and reasoning depth for everyday tasks", + }, + {"effort": "high", "description": "Greater reasoning depth for complex problems"}, + { + "effort": "xhigh", + "description": "Extra high reasoning depth for complex problems", + }, +] + +CODEX_BASE_INSTRUCTIONS = ( + "You are Codex, a coding agent. Help the user understand, modify, test, " + "and review code in their workspace. Follow the user's instructions, use " + "tools when needed, and communicate concise progress and verification." +) + + +@dataclass(frozen=True, slots=True) +class _CatalogCandidate: + slug: str + provider_model_ref: str + display_name: str + force_no_thinking: bool + + +def build_codex_model_catalog(models_response: Mapping[str, Any]) -> dict[str, Any]: + """Convert FCC `/v1/models` data into Codex `model_catalog_json` payload.""" + + candidates = list(_catalog_candidates(models_response)) + normal_provider_refs = { + candidate.provider_model_ref + for candidate in candidates + if not candidate.force_no_thinking + } + models: list[dict[str, Any]] = [] + seen_slugs: set[str] = set() + + for candidate in candidates: + if ( + candidate.force_no_thinking + and candidate.provider_model_ref in normal_provider_refs + ): + continue + if candidate.slug in seen_slugs: + continue + seen_slugs.add(candidate.slug) + models.append(_codex_catalog_entry(candidate, priority=len(models))) + + return {"models": models} + + +def write_codex_model_catalog(catalog_path: Path, catalog: Mapping[str, Any]) -> None: + """Atomically write a Codex model catalog JSON file.""" + + catalog_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = catalog_path.with_name(f".{catalog_path.name}.{uuid.uuid4().hex}.tmp") + temp_path.write_text( + json.dumps(catalog, ensure_ascii=True, indent=2) + "\n", + encoding="utf-8", + ) + temp_path.replace(catalog_path) + + +def _catalog_candidates( + models_response: Mapping[str, Any], +) -> list[_CatalogCandidate]: + data = models_response.get("data") + if not isinstance(data, list): + return [] + + candidates: list[_CatalogCandidate] = [] + for item in data: + if not isinstance(item, Mapping): + continue + model_id = _string_value(item.get("id")) + if model_id is None: + continue + candidate = _candidate_from_model_id( + model_id, + display_name=_string_value(item.get("display_name")) or model_id, + ) + if candidate is not None: + candidates.append(candidate) + return candidates + + +def _candidate_from_model_id( + model_id: str, *, display_name: str +) -> _CatalogCandidate | None: + prefix, separator, remainder = model_id.partition("/") + if not separator: + return None + + if prefix == GATEWAY_MODEL_ID_PREFIX: + if not _is_provider_model_ref(remainder): + return None + return _CatalogCandidate( + slug=remainder, + provider_model_ref=remainder, + display_name=display_name, + force_no_thinking=False, + ) + + if prefix == NO_THINKING_GATEWAY_MODEL_ID_PREFIX: + if not _is_provider_model_ref(remainder): + return None + return _CatalogCandidate( + slug=model_id, + provider_model_ref=remainder, + display_name=display_name, + force_no_thinking=True, + ) + + if prefix in SUPPORTED_PROVIDER_IDS and remainder: + return _CatalogCandidate( + slug=model_id, + provider_model_ref=model_id, + display_name=display_name, + force_no_thinking=False, + ) + + return None + + +def _codex_catalog_entry( + candidate: _CatalogCandidate, *, priority: int +) -> dict[str, Any]: + return { + "slug": candidate.slug, + "display_name": candidate.display_name, + "description": "Free Claude Code provider model", + "default_reasoning_level": "medium", + "supported_reasoning_levels": SUPPORTED_REASONING_LEVELS, + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": True, + "priority": priority, + "additional_speed_tiers": [], + "service_tiers": [], + "base_instructions": CODEX_BASE_INSTRUCTIONS, + "supports_reasoning_summaries": True, + "default_reasoning_summary": "none", + "support_verbosity": True, + "default_verbosity": "low", + "apply_patch_tool_type": "freeform", + "web_search_tool_type": "text_and_image", + "truncation_policy": {"mode": "tokens", "limit": 10000}, + "supports_parallel_tool_calls": True, + "supports_image_detail_original": True, + "context_window": 200000, + "max_context_window": 200000, + "effective_context_window_percent": 95, + "experimental_supported_tools": [], + "input_modalities": ["text"], + "supports_search_tool": True, + "use_responses_lite": False, + } + + +def _is_provider_model_ref(value: str) -> bool: + provider_id, separator, provider_model = value.partition("/") + return bool(separator and provider_model and provider_id in SUPPORTED_PROVIDER_IDS) + + +def _string_value(value: Any) -> str | None: + return value if isinstance(value, str) else None diff --git a/src/free_claude_code/cli/launchers/common.py b/src/free_claude_code/cli/launchers/common.py new file mode 100644 index 0000000..a4674cf --- /dev/null +++ b/src/free_claude_code/cli/launchers/common.py @@ -0,0 +1,91 @@ +"""Shared process helpers for installed client CLI launchers.""" + +import shutil +import subprocess +import sys +from collections.abc import Mapping +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from free_claude_code.cli.process_registry import ( + kill_pid_tree_best_effort, + register_pid, + unregister_pid, +) + +PROXY_PREFLIGHT_PATH = "/health" +PROXY_PREFLIGHT_TIMEOUT_SECONDS = 1.5 + + +def preflight_proxy(proxy_root_url: str) -> str | None: + """Return an error message when the local proxy health check is unreachable.""" + + url = f"{proxy_root_url.rstrip('/')}{PROXY_PREFLIGHT_PATH}" + request = Request(url, method="GET") + try: + with urlopen(request, timeout=PROXY_PREFLIGHT_TIMEOUT_SECONDS) as response: + status_code = response.getcode() + except HTTPError as exc: + return f"returned HTTP {exc.code}" + except URLError as exc: + return str(exc.reason) + except OSError as exc: + return str(exc) + + if not 200 <= status_code < 300: + return f"returned HTTP {status_code}" + return None + + +def resolve_client_binary( + *, + binary_name: str, + display_name: str, + install_hint: str, +) -> str: + """Resolve an installed client binary or exit with a user-facing hint.""" + + client_command = shutil.which(binary_name) + if client_command is None: + print( + f"Could not find {display_name} command: {binary_name}", + file=sys.stderr, + ) + print(install_hint, file=sys.stderr) + raise SystemExit(127) + return client_command + + +def run_client_process( + *, + command: list[str], + env: Mapping[str, str], + binary_name: str, + display_name: str, + install_hint: str, +) -> None: + """Run a client CLI command and mirror its exit code.""" + + process: subprocess.Popen[bytes] | None = None + try: + process = subprocess.Popen(command, env=dict(env)) + if process.pid: + register_pid(process.pid) + return_code = process.wait() + except FileNotFoundError: + print( + f"Could not find {display_name} command: {binary_name}", + file=sys.stderr, + ) + print(install_hint, file=sys.stderr) + raise SystemExit(127) from None + except KeyboardInterrupt: + if process is not None and process.pid: + kill_pid_tree_best_effort(process.pid) + process.wait() + raise + finally: + if process is not None and process.pid: + unregister_pid(process.pid) + + raise SystemExit(return_code) diff --git a/src/free_claude_code/cli/launchers/pi.py b/src/free_claude_code/cli/launchers/pi.py new file mode 100644 index 0000000..59e2553 --- /dev/null +++ b/src/free_claude_code/cli/launchers/pi.py @@ -0,0 +1,163 @@ +"""Installed `fcc-pi` launcher.""" + +import os +import subprocess +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path + +from free_claude_code.cli.proxy_auth import proxy_auth_token +from free_claude_code.config.server_urls import local_proxy_root_url +from free_claude_code.config.settings import get_settings + +from .common import preflight_proxy, resolve_client_binary, run_client_process + +_API_KEY_ENV = "FCC_PI_API_KEY" +_BASE_URL_ENV = "FCC_PI_BASE_URL" +_BINARY_NAME = "pi" +_DISPLAY_NAME = "Pi" +_HELP_TIMEOUT_SECONDS = 5.0 +_MODEL_SCOPE = "free-claude-code/**" +_REQUIRED_HELP_MARKERS = ("--extension", "--models") +_PASSTHROUGH_COMMANDS = frozenset( + {"config", "install", "list", "remove", "uninstall", "update"} +) +_PASSTHROUGH_FLAGS = frozenset({"--help", "-h", "--version", "-v"}) + + +def launch(argv: Sequence[str] | None = None) -> None: + """Launch Pi with a process-local Free Claude Code provider.""" + + args = list(sys.argv[1:] if argv is None else argv) + install_hint = pi_install_hint() + binary_path = resolve_client_binary( + binary_name=_BINARY_NAME, + display_name=_DISPLAY_NAME, + install_hint=install_hint, + ) + if not pi_binary_is_compatible(binary_path): + print( + f"The 'pi' command at {binary_path} is not a compatible Pi Coding Agent.", + file=sys.stderr, + ) + print(install_hint, file=sys.stderr) + raise SystemExit(126) + + if is_pi_passthrough(args): + run_client_process( + command=[binary_path, *args], + env=os.environ, + binary_name=_BINARY_NAME, + display_name=_DISPLAY_NAME, + install_hint=install_hint, + ) + return + + settings = get_settings() + proxy_root_url = local_proxy_root_url(settings) + if error := preflight_proxy(proxy_root_url): + print( + f"Free Claude Code proxy is not reachable at {proxy_root_url}: {error}", + file=sys.stderr, + ) + print("Start it in another terminal with: fcc-server", file=sys.stderr) + raise SystemExit(1) + + extension_path = pi_extension_path() + if not extension_path.is_file(): + print( + "Free Claude Code's bundled Pi extension is missing. Reinstall FCC.", + file=sys.stderr, + ) + raise SystemExit(1) + + run_client_process( + command=build_pi_launcher_command( + binary_path=binary_path, + extension_path=extension_path, + argv=args, + ), + env=build_pi_launcher_env( + proxy_root_url=proxy_root_url, + auth_token=settings.anthropic_auth_token, + base_env=os.environ, + ), + binary_name=_BINARY_NAME, + display_name=_DISPLAY_NAME, + install_hint=install_hint, + ) + + +def build_pi_launcher_command( + *, + binary_path: str, + extension_path: Path, + argv: Sequence[str], +) -> list[str]: + """Return a Pi session command with ephemeral FCC provider registration.""" + + return [ + binary_path, + "-e", + str(extension_path), + "--models", + _MODEL_SCOPE, + *argv, + ] + + +def build_pi_launcher_env( + *, + proxy_root_url: str, + auth_token: str, + base_env: Mapping[str, str], +) -> dict[str, str]: + """Return a Pi environment containing only FCC-owned proxy variables.""" + + env = { + key: value for key, value in base_env.items() if not key.startswith("FCC_PI_") + } + env[_BASE_URL_ENV] = proxy_root_url.rstrip("/") + env[_API_KEY_ENV] = proxy_auth_token(auth_token) + return env + + +def is_pi_passthrough(argv: Sequence[str]) -> bool: + """Return whether Pi must receive argv unchanged as a non-session command.""" + + return bool(argv) and ( + argv[0] in _PASSTHROUGH_COMMANDS or argv[0] in _PASSTHROUGH_FLAGS + ) + + +def pi_extension_path() -> Path: + """Return the absolute installed path to the bundled Pi extension.""" + + return Path(__file__).with_name("pi_extension.ts").resolve() + + +def pi_binary_is_compatible(binary_path: str) -> bool: + """Return whether an executable exposes the Pi features FCC requires.""" + + try: + result = subprocess.run( + [binary_path, "--help"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=_HELP_TIMEOUT_SECONDS, + ) + except OSError, subprocess.TimeoutExpired: + return False + return result.returncode == 0 and all( + marker in result.stdout for marker in _REQUIRED_HELP_MARKERS + ) + + +def pi_install_hint(platform: str | None = None) -> str: + """Return Pi's official installer command for the current platform.""" + + if (platform or sys.platform) == "win32": + return 'Install Pi with: powershell -c "irm https://pi.dev/install.ps1 | iex"' + return "Install Pi with: curl -fsSL https://pi.dev/install.sh | sh" diff --git a/src/free_claude_code/cli/launchers/pi_extension.ts b/src/free_claude_code/cli/launchers/pi_extension.ts new file mode 100644 index 0000000..f738a71 --- /dev/null +++ b/src/free_claude_code/cli/launchers/pi_extension.ts @@ -0,0 +1,157 @@ +import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent"; + +const API_KEY_ENV = "FCC_PI_API_KEY"; +const BASE_URL_ENV = "FCC_PI_BASE_URL"; +const CATALOG_TIMEOUT_MS = 3000; +const DEFAULT_CONTEXT_WINDOW = 128000; +const DEFAULT_MAX_TOKENS = 16384; +const NORMAL_MODEL_PREFIX = "anthropic/"; +const NO_THINKING_MODEL_PREFIX = "claude-3-freecc-no-thinking/"; + +function requireEnvironment(name: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(`Missing required ${name} environment variable.`); + } + return value; +} + +function normalizeBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${BASE_URL_ENV} is not a valid URL.`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${BASE_URL_ENV} must use http or https.`); + } + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/+$/, ""); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function catalogModelIds(payload: unknown): string[] { + if (!isRecord(payload) || payload.object !== "list" || !Array.isArray(payload.data)) { + throw new Error("FCC model catalog returned an invalid response shape."); + } + + const ids: string[] = []; + for (const entry of payload.data) { + if (!isRecord(entry) || typeof entry.id !== "string") continue; + const id = entry.id.trim(); + if (id) ids.push(id); + } + return ids; +} + +function providerModelRef(id: string, prefix: string): string | undefined { + if (!id.startsWith(prefix)) return undefined; + const parts = id.slice(prefix.length).split("/"); + if (parts.length < 2 || parts.some((part) => !part)) return undefined; + return parts.join("/"); +} + +function modelDefinition(providerModel: string, reasoning: boolean): ProviderModelConfig { + return { + id: providerModel, + name: providerModel, + reasoning, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }; +} + +export function projectFccModels(payload: unknown): ProviderModelConfig[] { + const ids = catalogModelIds(payload); + const normalModels = new Set(); + for (const id of ids) { + const providerModel = providerModelRef(id, NORMAL_MODEL_PREFIX); + if (providerModel) normalModels.add(providerModel); + } + + const models: ProviderModelConfig[] = []; + const seen = new Set(); + for (const id of ids) { + const normalModel = providerModelRef(id, NORMAL_MODEL_PREFIX); + if (normalModel) { + if (!seen.has(normalModel)) { + seen.add(normalModel); + models.push(modelDefinition(normalModel, true)); + } + continue; + } + + const noThinkingModel = providerModelRef(id, NO_THINKING_MODEL_PREFIX); + if (!noThinkingModel || normalModels.has(noThinkingModel) || seen.has(noThinkingModel)) continue; + seen.add(noThinkingModel); + models.push(modelDefinition(noThinkingModel, false)); + } + + if (models.length === 0) { + throw new Error("FCC model catalog contains no routable provider models."); + } + return models; +} + +function requestIdSuffix(response: Response): string { + const requestId = response.headers.get("request-id") ?? response.headers.get("x-request-id"); + return requestId ? ` (request ${requestId})` : ""; +} + +async function fetchFccModels(baseUrl: string, apiKey: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), CATALOG_TIMEOUT_MS); + try { + let response: Response; + try { + response = await fetch(`${baseUrl}/v1/models`, { + headers: { "X-API-Key": apiKey }, + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`FCC model catalog timed out after ${CATALOG_TIMEOUT_MS}ms.`); + } + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Could not reach the FCC model catalog: ${message}`); + } + + if (!response.ok) { + throw new Error(`FCC model catalog returned HTTP ${response.status}${requestIdSuffix(response)}.`); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`FCC model catalog timed out after ${CATALOG_TIMEOUT_MS}ms.`); + } + throw new Error(`FCC model catalog returned invalid JSON${requestIdSuffix(response)}.`); + } + return projectFccModels(payload); + } finally { + clearTimeout(timeout); + } +} + +export default async function freeClaudeCode(pi: ExtensionAPI): Promise { + const baseUrl = normalizeBaseUrl(requireEnvironment(BASE_URL_ENV)); + const apiKey = requireEnvironment(API_KEY_ENV); + const models = await fetchFccModels(baseUrl, apiKey); + + pi.registerProvider("free-claude-code", { + name: "Free Claude Code", + baseUrl, + apiKey: `$${API_KEY_ENV}`, + api: "anthropic-messages", + models, + }); +} diff --git a/src/free_claude_code/cli/managed/__init__.py b/src/free_claude_code/cli/managed/__init__.py new file mode 100644 index 0000000..92c7911 --- /dev/null +++ b/src/free_claude_code/cli/managed/__init__.py @@ -0,0 +1,6 @@ +"""Managed Claude Code sessions used by messaging.""" + +from .manager import ManagedClaudeSessionManager +from .session import ManagedClaudeSession + +__all__ = ["ManagedClaudeSession", "ManagedClaudeSessionManager"] diff --git a/src/free_claude_code/cli/managed/claude.py b/src/free_claude_code/cli/managed/claude.py new file mode 100644 index 0000000..bdd6934 --- /dev/null +++ b/src/free_claude_code/cli/managed/claude.py @@ -0,0 +1,215 @@ +"""Managed Claude Code task command, environment, and stdout parsing.""" + +import json +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field +from typing import Any + +from loguru import logger + +from free_claude_code.cli.claude_env import ( + CLAUDE_BINARY_NAME, + build_claude_proxy_env, +) + +MANAGED_CLAUDE_MODEL_TIER = "opus" + + +@dataclass(frozen=True, slots=True) +class ManagedClaudeTaskRequest: + """One prompt execution request for a managed Claude Code subprocess.""" + + prompt: str + session_id: str | None = None + fork_session: bool = False + + +@dataclass(frozen=True, slots=True) +class ManagedClaudeInvocation: + """Concrete subprocess invocation assembled for a managed Claude task.""" + + argv: tuple[str, ...] + env: dict[str, str] + cwd: str + trace_metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class ManagedClaudeConfig: + """Configuration for a managed Claude Code subprocess.""" + + workspace_path: str + proxy_root_url: str + allowed_dirs: list[str] = field(default_factory=list) + claude_bin: str = CLAUDE_BINARY_NAME + auth_token: str = "" + + +@dataclass(slots=True) +class ManagedClaudeParseState: + """Mutable stdout parser state for one managed Claude Code task run.""" + + log_raw_cli_diagnostics: bool = False + session_id_extracted: bool = False + + +def build_managed_claude_invocation( + *, + config: ManagedClaudeConfig, + request: ManagedClaudeTaskRequest, + base_env: Mapping[str, str], +) -> ManagedClaudeInvocation: + """Build a Claude Code stream-json subprocess invocation.""" + + cmd = build_managed_claude_command( + claude_bin=config.claude_bin, + prompt=request.prompt, + session_id=request.session_id, + fork_session=request.fork_session, + allowed_dirs=config.allowed_dirs, + ) + resume_session_id = ( + request.session_id + if request.session_id and not request.session_id.startswith("pending_") + else None + ) + return ManagedClaudeInvocation( + argv=tuple(cmd), + env=build_managed_claude_env( + proxy_root_url=config.proxy_root_url, + auth_token=config.auth_token, + base_env=base_env, + ), + cwd=config.workspace_path, + trace_metadata={ + "client_cli_id": "claude", + "resume_session_id": resume_session_id, + "fork_session": request.fork_session, + "prompt": request.prompt, + "cwd": config.workspace_path, + "claude_binary": config.claude_bin, + "managed_model_tier": MANAGED_CLAUDE_MODEL_TIER, + "cli_argv": cmd, + }, + ) + + +def build_managed_claude_env( + *, + proxy_root_url: str, + auth_token: str, + base_env: Mapping[str, str], +) -> dict[str, str]: + """Return a Claude Code task environment that targets the local proxy.""" + + env = build_claude_proxy_env( + proxy_root_url=proxy_root_url, + auth_token=auth_token, + base_env=base_env, + ) + env["TERM"] = "dumb" + env["PYTHONIOENCODING"] = "utf-8" + return env + + +def build_managed_claude_command( + *, + claude_bin: str, + prompt: str, + session_id: str | None, + fork_session: bool, + allowed_dirs: list[str], +) -> list[str]: + """Return the Claude Code stream-json command for a managed task.""" + + if session_id and not session_id.startswith("pending_"): + cmd = [ + claude_bin, + "--resume", + session_id, + ] + if fork_session: + cmd.append("--fork-session") + cmd += [ + "--model", + MANAGED_CLAUDE_MODEL_TIER, + "-p", + prompt, + "--output-format", + "stream-json", + "--dangerously-skip-permissions", + "--verbose", + ] + else: + cmd = [ + claude_bin, + "--model", + MANAGED_CLAUDE_MODEL_TIER, + "-p", + prompt, + "--output-format", + "stream-json", + "--dangerously-skip-permissions", + "--verbose", + ] + + for directory in allowed_dirs: + cmd.extend(["--add-dir", directory]) + + return cmd + + +def parse_managed_claude_stdout_line( + line: str, state: ManagedClaudeParseState +) -> Iterable[Any]: + """Parse one Claude Code stream-json stdout line.""" + + try: + event = json.loads(line) + except json.JSONDecodeError: + if state.log_raw_cli_diagnostics: + logger.debug("Non-JSON output: {}", line) + else: + logger.debug("Non-JSON CLI line: char_len={}", len(line)) + yield {"type": "raw", "content": line} + return + + if not state.session_id_extracted: + extracted_id = extract_managed_claude_session_id(event) + if extracted_id: + state.session_id_extracted = True + logger.info("Extracted session ID: {}", extracted_id) + yield {"type": "session_info", "session_id": extracted_id} + + yield event + + +def extract_managed_claude_session_id(event: Any) -> str | None: + """Extract a Claude Code session ID from supported stream-json event shapes.""" + + if not isinstance(event, dict): + return None + + if session_id := _string_value(event.get("session_id")): + return session_id + if session_id := _string_value(event.get("sessionId")): + return session_id + + for key in ("init", "system", "result", "metadata"): + nested = event.get(key) + if not isinstance(nested, dict): + continue + if session_id := _string_value(nested.get("session_id")): + return session_id + if session_id := _string_value(nested.get("sessionId")): + return session_id + + conversation = event.get("conversation") + if isinstance(conversation, dict): + return _string_value(conversation.get("id")) + + return None + + +def _string_value(value: Any) -> str | None: + return value if isinstance(value, str) else None diff --git a/src/free_claude_code/cli/managed/diagnostics.py b/src/free_claude_code/cli/managed/diagnostics.py new file mode 100644 index 0000000..85c6063 --- /dev/null +++ b/src/free_claude_code/cli/managed/diagnostics.py @@ -0,0 +1,47 @@ +"""Managed Claude Code diagnostic classification.""" + +from dataclasses import dataclass + +_BENIGN_STDERR_MARKERS = ("claude.ai connectors are disabled",) + + +@dataclass(frozen=True, slots=True) +class ManagedClaudeStderrDiagnostics: + """Classified stderr lines from one managed Claude Code invocation.""" + + benign_lines: tuple[str, ...] + fatal_lines: tuple[str, ...] + + @property + def fatal_text(self) -> str | None: + text = "\n".join(self.fatal_lines).strip() + return text or None + + @property + def has_benign(self) -> bool: + return bool(self.benign_lines) + + +def classify_managed_claude_stderr( + stderr_text: str, +) -> ManagedClaudeStderrDiagnostics: + """Classify known benign Claude Code diagnostics separately from failures.""" + benign_lines: list[str] = [] + fatal_lines: list[str] = [] + for line in _stderr_lines(stderr_text): + lowered = line.lower() + if any(marker in lowered for marker in _BENIGN_STDERR_MARKERS): + benign_lines.append(line) + else: + fatal_lines.append(line) + return ManagedClaudeStderrDiagnostics( + benign_lines=tuple(benign_lines), + fatal_lines=tuple(fatal_lines), + ) + + +def _stderr_lines(stderr_text: str) -> tuple[str, ...]: + stripped = stderr_text.strip() + if not stripped: + return () + return tuple(line.strip() for line in stripped.splitlines() if line.strip()) diff --git a/src/free_claude_code/cli/managed/manager.py b/src/free_claude_code/cli/managed/manager.py new file mode 100644 index 0000000..30bd603 --- /dev/null +++ b/src/free_claude_code/cli/managed/manager.py @@ -0,0 +1,207 @@ +"""Managed Claude Code session pool for messaging.""" + +import asyncio +import uuid + +from loguru import logger + +from free_claude_code.cli.claude_env import CLAUDE_BINARY_NAME + +from .session import ManagedClaudeSession + + +class ManagedClaudeSessionManager: + """ + Manages multiple Claude Code sessions for parallel conversation processing. + + Each new conversation gets its own subprocess. Replies to existing + conversations reuse the same session instance. + """ + + def __init__( + self, + workspace_path: str, + proxy_root_url: str, + allowed_dirs: list[str] | None = None, + claude_bin: str = CLAUDE_BINARY_NAME, + auth_token: str = "", + *, + log_raw_cli_diagnostics: bool = False, + log_messaging_error_details: bool = False, + ): + """ + Initialize the session manager. + + Args: + workspace_path: Working directory for CLI processes + proxy_root_url: Root URL for the local proxy + allowed_dirs: Directories the CLI is allowed to access + """ + self.workspace = workspace_path + self.proxy_root_url = proxy_root_url + self.allowed_dirs = allowed_dirs or [] + self.claude_bin = claude_bin + self.auth_token = auth_token + self._log_raw_cli_diagnostics = log_raw_cli_diagnostics + self._log_messaging_error_details = log_messaging_error_details + + self._sessions: dict[str, ManagedClaudeSession] = {} + self._pending_sessions: dict[str, ManagedClaudeSession] = {} + self._temp_to_real: dict[str, str] = {} + self._real_to_temp: dict[str, str] = {} + self._closing_sessions: set[ManagedClaudeSession] = set() + self._lock = asyncio.Lock() + + def _session_for_id(self, session_id: str) -> ManagedClaudeSession | None: + lookup_id = self._temp_to_real.get(session_id, session_id) + session = self._sessions.get(lookup_id) + if session is not None: + return session + return self._pending_sessions.get(lookup_id) + + def _forget_session(self, session: ManagedClaudeSession) -> None: + pending_ids = [ + session_id + for session_id, owned in self._pending_sessions.items() + if owned is session + ] + real_ids = [ + session_id + for session_id, owned in self._sessions.items() + if owned is session + ] + for session_id in pending_ids: + self._pending_sessions.pop(session_id, None) + for real_id in real_ids: + self._sessions.pop(real_id, None) + temp_id = self._real_to_temp.pop(real_id, None) + if temp_id is not None: + self._temp_to_real.pop(temp_id, None) + self._closing_sessions.discard(session) + + async def get_or_create_session( + self, session_id: str | None = None + ) -> tuple[ManagedClaudeSession, str, bool]: + """ + Get an existing session or create a new one. + + Returns: + Tuple of (session instance, session_id, is_new_session) + """ + async with self._lock: + if session_id: + lookup_id = self._temp_to_real.get(session_id, session_id) + + if lookup_id in self._sessions: + session = self._sessions[lookup_id] + if session in self._closing_sessions: + raise RuntimeError("Managed Claude session is closing.") + return session, lookup_id, False + if lookup_id in self._pending_sessions: + session = self._pending_sessions[lookup_id] + if session in self._closing_sessions: + raise RuntimeError("Managed Claude session is closing.") + return session, lookup_id, False + + temp_id = session_id if session_id else f"pending_{uuid.uuid4().hex[:8]}" + + new_session = ManagedClaudeSession( + workspace_path=self.workspace, + proxy_root_url=self.proxy_root_url, + allowed_dirs=self.allowed_dirs, + claude_bin=self.claude_bin, + auth_token=self.auth_token, + log_raw_cli_diagnostics=self._log_raw_cli_diagnostics, + ) + self._pending_sessions[temp_id] = new_session + + return new_session, temp_id, True + + async def register_real_session_id( + self, temp_id: str, real_session_id: str + ) -> bool: + """Register the real session ID from CLI output.""" + async with self._lock: + session = self._pending_sessions.get(temp_id) + if session is None: + logger.warning(f"Temp session {temp_id} not found") + return False + if session in self._closing_sessions: + logger.warning("Cannot register a closing managed Claude session") + return False + existing = self._session_for_id(real_session_id) + if existing is not None and existing is not session: + logger.warning( + "Cannot register managed Claude session: real ID is already owned" + ) + return False + + self._pending_sessions.pop(temp_id) + self._sessions[real_session_id] = session + self._temp_to_real[temp_id] = real_session_id + self._real_to_temp[real_session_id] = temp_id + + logger.info(f"Registered session: {temp_id} -> {real_session_id}") + return True + + async def remove_session(self, session_id: str) -> bool: + """Remove a session from the manager.""" + async with self._lock: + session = self._session_for_id(session_id) + if session is None: + return False + self._closing_sessions.add(session) + stopped = await session.stop() + if not stopped: + return False + self._forget_session(session) + return True + + async def stop_all(self) -> None: + """Stop all sessions.""" + async with self._lock: + all_sessions = list( + dict.fromkeys( + [ + *self._sessions.values(), + *self._pending_sessions.values(), + *self._closing_sessions, + ] + ) + ) + self._closing_sessions.update(all_sessions) + failures = 0 + for session in all_sessions: + try: + stopped = await session.stop() + except Exception as e: + stopped = False + if self._log_messaging_error_details: + logger.error( + "Error stopping session: {}: {}", + type(e).__name__, + e, + ) + else: + logger.error( + "Error stopping session: exc_type={}", + type(e).__name__, + ) + if stopped: + self._forget_session(session) + else: + failures += 1 + + if failures: + raise RuntimeError( + f"Managed Claude session shutdown failures: {failures}." + ) + logger.info("All sessions stopped") + + def get_stats(self) -> dict: + """Get session statistics.""" + return { + "active_sessions": len(self._sessions), + "pending_sessions": len(self._pending_sessions), + "busy_count": sum(1 for s in self._sessions.values() if s.is_busy), + } diff --git a/src/free_claude_code/cli/managed/session.py b/src/free_claude_code/cli/managed/session.py new file mode 100644 index 0000000..af0016e --- /dev/null +++ b/src/free_claude_code/cli/managed/session.py @@ -0,0 +1,293 @@ +"""Managed Claude Code subprocess session.""" + +import asyncio +import os +from collections.abc import AsyncGenerator + +from loguru import logger + +from free_claude_code.cli.process_registry import ( + kill_pid_tree_best_effort, + register_pid, + unregister_pid, +) +from free_claude_code.core.trace import trace_event + +from .claude import ( + ManagedClaudeConfig, + ManagedClaudeParseState, + ManagedClaudeTaskRequest, + build_managed_claude_invocation, + parse_managed_claude_stdout_line, +) +from .diagnostics import classify_managed_claude_stderr + +# Cap stderr capture so a runaway child cannot exhaust memory; pipe is still drained. +_MAX_STDERR_CAPTURE_BYTES = 256 * 1024 + + +class ManagedClaudeSession: + """Manages a single persistent Claude Code subprocess.""" + + def __init__( + self, + workspace_path: str, + proxy_root_url: str, + allowed_dirs: list[str] | None = None, + claude_bin: str = "claude", + auth_token: str = "", + *, + log_raw_cli_diagnostics: bool = False, + ): + self.config = ManagedClaudeConfig( + workspace_path=os.path.normpath(os.path.abspath(workspace_path)), + proxy_root_url=proxy_root_url, + allowed_dirs=[os.path.normpath(d) for d in (allowed_dirs or [])], + claude_bin=claude_bin, + auth_token=auth_token, + ) + self.workspace = self.config.workspace_path + self.proxy_root_url = self.config.proxy_root_url + self.allowed_dirs = self.config.allowed_dirs + self.claude_bin = self.config.claude_bin + self.auth_token = self.config.auth_token + self._log_raw_cli_diagnostics = log_raw_cli_diagnostics + self.process: asyncio.subprocess.Process | None = None + self.current_session_id: str | None = None + self._is_busy = False + self._cli_lock = asyncio.Lock() + self._lifecycle_lock = asyncio.Lock() + self._closed = False + + @staticmethod + async def _drain_stderr_bounded( + process: asyncio.subprocess.Process, + *, + max_bytes: int = _MAX_STDERR_CAPTURE_BYTES, + ) -> bytes: + """Read stderr concurrently with stdout to avoid subprocess pipe deadlocks. + + Retains at most ``max_bytes`` for logging; any excess is discarded, but + the pipe is read until EOF so a noisy child cannot fill the buffer and + block forever. + """ + if not process.stderr: + return b"" + parts: list[bytes] = [] + received = 0 + while True: + chunk = await process.stderr.read(65_536) + if not chunk: + break + if received < max_bytes: + take = min(len(chunk), max_bytes - received) + if take: + parts.append(chunk[:take]) + received += take + # If already at cap, keep reading and discarding until EOF. + return b"".join(parts) + + @property + def is_busy(self) -> bool: + """Check if a task is currently running.""" + return self._is_busy + + async def start_task( + self, prompt: str, session_id: str | None = None, fork_session: bool = False + ) -> AsyncGenerator[dict]: + """ + Start a new task or continue an existing session. + + Args: + prompt: The user's message/prompt + session_id: Optional session ID to resume + + Yields: + Event dictionaries from the CLI + """ + async with self._cli_lock: + process: asyncio.subprocess.Process | None = None + termination_confirmed = False + try: + async with self._lifecycle_lock: + if self._closed: + raise RuntimeError("Managed Claude session is closed.") + self._is_busy = True + invocation = build_managed_claude_invocation( + config=self.config, + request=ManagedClaudeTaskRequest( + prompt=prompt, + session_id=session_id, + fork_session=fork_session, + ), + base_env=os.environ, + ) + + trace_event( + stage="claude_cli", + event="claude_cli.process.launch", + source="claude_cli", + **invocation.trace_metadata, + ) + + process = await asyncio.create_subprocess_exec( + *invocation.argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=invocation.cwd, + env=invocation.env, + ) + self.process = process + if process.pid: + register_pid(process.pid) + + if not process.stdout: + yield {"type": "exit", "code": 1} + return + + parse_state = ManagedClaudeParseState( + log_raw_cli_diagnostics=self._log_raw_cli_diagnostics + ) + buffer = bytearray() + stderr_task: asyncio.Task[bytes] | None = None + if process.stderr: + stderr_task = asyncio.create_task( + self._drain_stderr_bounded(process) + ) + + try: + while True: + chunk = await process.stdout.read(65536) + if not chunk: + if buffer: + line_str = buffer.decode( + "utf-8", errors="replace" + ).strip() + if line_str: + async for event in self._handle_line_gen( + line_str, parse_state + ): + yield event + break + + buffer.extend(chunk) + + while True: + newline_pos = buffer.find(b"\n") + if newline_pos == -1: + break + + line = buffer[:newline_pos] + buffer = buffer[newline_pos + 1 :] + + line_str = line.decode("utf-8", errors="replace").strip() + if line_str: + async for event in self._handle_line_gen( + line_str, parse_state + ): + yield event + except asyncio.CancelledError: + # Cancelling the handler task should not leave a Claude CLI + # subprocess running in the background. + await asyncio.shield(self.stop()) + raise + finally: + stderr_bytes = b"" + if stderr_task is not None: + stderr_bytes = await stderr_task + + stderr_text = None + if stderr_bytes: + raw_stderr_text = stderr_bytes.decode( + "utf-8", errors="replace" + ).strip() + if raw_stderr_text: + diagnostics = classify_managed_claude_stderr(raw_stderr_text) + if diagnostics.has_benign: + logger.info( + "Claude CLI benign stderr diagnostics: lines={}", + len(diagnostics.benign_lines), + ) + stderr_text = diagnostics.fatal_text + if stderr_text: + if self._log_raw_cli_diagnostics: + logger.error("Claude CLI stderr: {}", stderr_text) + else: + logger.error( + "Claude CLI stderr: bytes={} text_chars={}", + len(stderr_bytes), + len(stderr_text), + ) + logger.info("CLI_SESSION: Yielding error event from stderr") + yield {"type": "error", "error": {"message": stderr_text}} + + return_code = await process.wait() + termination_confirmed = True + logger.info( + f"Claude CLI exited with code {return_code}, stderr_present={bool(stderr_text)}" + ) + if return_code != 0 and not stderr_text: + logger.warning( + f"CLI_SESSION: Process exited with code {return_code} but no stderr captured" + ) + yield { + "type": "exit", + "code": return_code, + "stderr": stderr_text, + } + finally: + self._is_busy = False + if ( + process + and process.pid + and (termination_confirmed or process.returncode is not None) + ): + unregister_pid(process.pid) + + async def _handle_line_gen( + self, line_str: str, parse_state: ManagedClaudeParseState + ) -> AsyncGenerator[dict]: + """Process a single line and yield events.""" + for event in parse_managed_claude_stdout_line(line_str, parse_state): + if isinstance(event, dict) and event.get("type") == "session_info": + session_id = event.get("session_id") + if isinstance(session_id, str): + self.current_session_id = session_id + yield event + + async def stop(self) -> bool: + """Stop the CLI process, retaining PID ownership until exit is confirmed.""" + async with self._lifecycle_lock: + self._closed = True + process = self.process + if process is None: + return True + if process.returncode is not None: + if process.pid: + unregister_pid(process.pid) + return True + + try: + logger.info(f"Stopping Claude CLI process {process.pid}") + kill_pid_tree_best_effort(process.pid) + try: + await asyncio.wait_for(process.wait(), timeout=5.0) + except TimeoutError: + process.kill() + await process.wait() + if process.pid: + unregister_pid(process.pid) + return True + except Exception as e: + if self._log_raw_cli_diagnostics: + logger.error( + "Error stopping process: {}: {}", + type(e).__name__, + e, + ) + else: + logger.error( + "Error stopping process: exc_type={}", + type(e).__name__, + ) + return False diff --git a/src/free_claude_code/cli/process_registry.py b/src/free_claude_code/cli/process_registry.py new file mode 100644 index 0000000..3b28964 --- /dev/null +++ b/src/free_claude_code/cli/process_registry.py @@ -0,0 +1,76 @@ +"""Track and clean up spawned CLI subprocesses. + +This is a safety net for cases where the server is interrupted (Ctrl+C) and the +FastAPI lifespan cleanup doesn't run to completion. We only track processes we +spawn so we don't accidentally kill unrelated system processes. +""" + +import atexit +import os +import signal +import subprocess +import threading + +from loguru import logger + +_lock = threading.Lock() +_pids: set[int] = set() +_atexit_registered = False + + +def ensure_atexit_registered() -> None: + global _atexit_registered + with _lock: + if _atexit_registered: + return + atexit.register(kill_all_best_effort) + _atexit_registered = True + + +def register_pid(pid: int) -> None: + if not pid: + return + ensure_atexit_registered() + with _lock: + _pids.add(int(pid)) + + +def unregister_pid(pid: int) -> None: + if not pid: + return + with _lock: + _pids.discard(int(pid)) + + +def kill_pid_tree_best_effort(pid: int) -> None: + """Kill a tracked process and its children where the platform supports it.""" + if not pid: + return + if os.name == "nt": + try: + # /T kills child processes, /F forces termination. + subprocess.run( + ["taskkill", "/PID", str(pid), "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + except Exception as e: + logger.debug("process_registry: taskkill failed pid=%s: %s", pid, e) + return + + # Best-effort fallback for non-Windows. + try: + os.kill(pid, signal.SIGTERM) + except Exception as e: + logger.debug("process_registry: terminate failed pid=%s: %s", pid, e) + + +def kill_all_best_effort() -> None: + """Kill any still-running registered pids (best-effort).""" + with _lock: + pids = list(_pids) + _pids.clear() + + for pid in pids: + kill_pid_tree_best_effort(pid) diff --git a/src/free_claude_code/cli/proxy_auth.py b/src/free_claude_code/cli/proxy_auth.py new file mode 100644 index 0000000..7264ab7 --- /dev/null +++ b/src/free_claude_code/cli/proxy_auth.py @@ -0,0 +1,9 @@ +"""Shared proxy-auth policy for FCC client launchers.""" + +PROXY_NO_AUTH_SENTINEL = "fcc-no-auth" + + +def proxy_auth_token(auth_token: str) -> str: + """Return the configured proxy token or the no-auth client marker.""" + + return auth_token.strip() or PROXY_NO_AUTH_SENTINEL diff --git a/src/free_claude_code/config/__init__.py b/src/free_claude_code/config/__init__.py new file mode 100644 index 0000000..87b0151 --- /dev/null +++ b/src/free_claude_code/config/__init__.py @@ -0,0 +1,5 @@ +"""Configuration management.""" + +from .settings import Settings, get_settings + +__all__ = ["Settings", "get_settings"] diff --git a/src/free_claude_code/config/admin/__init__.py b/src/free_claude_code/config/admin/__init__.py new file mode 100644 index 0000000..71e00b8 --- /dev/null +++ b/src/free_claude_code/config/admin/__init__.py @@ -0,0 +1 @@ +"""Admin configuration schema, persistence, and presentation metadata.""" diff --git a/src/free_claude_code/config/admin/manifest.py b/src/free_claude_code/config/admin/manifest.py new file mode 100644 index 0000000..1c026a8 --- /dev/null +++ b/src/free_claude_code/config/admin/manifest.py @@ -0,0 +1,698 @@ +"""Admin configuration manifest.""" + +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Literal + +from free_claude_code.config.settings import Settings + +from .provider_manifest import provider_field_specs + +FieldType = Literal[ + "text", + "secret", + "number", + "boolean", + "tri_boolean", + "select", + "textarea", +] + + +@dataclass(frozen=True, slots=True) +class ConfigSectionSpec: + """A group of config fields rendered together in the admin UI.""" + + section_id: str + label: str + description: str + advanced: bool = False + + +@dataclass(frozen=True, slots=True) +class ConfigFieldSpec: + """Typed metadata for one env-backed admin setting.""" + + key: str + label: str + section_id: str + field_type: FieldType = "text" + settings_attr: str | None = None + default: str = "" + options: tuple[str, ...] = () + secret: bool = False + advanced: bool = False + restart_required: bool = False + session_sensitive: bool = False + description: str = "" + + +SECTIONS: tuple[ConfigSectionSpec, ...] = ( + ConfigSectionSpec( + "providers", + "Providers", + "Provider keys, local endpoints, and proxy settings.", + ), + ConfigSectionSpec( + "models", + "Model Routing", + "Provider-prefixed models used for Claude model tiers.", + ), + ConfigSectionSpec( + "thinking", + "Thinking", + "Global and tier-specific thinking behavior.", + ), + ConfigSectionSpec( + "runtime", + "Runtime", + "Server API token, rate limits, timeouts, and process settings.", + ), + ConfigSectionSpec( + "messaging", + "Messaging", + "Discord, Telegram, CLI workspace, and session settings.", + ), + ConfigSectionSpec( + "voice", + "Voice", + "Voice note transcription settings.", + ), + ConfigSectionSpec( + "web_tools", + "Web Tools", + "Local Anthropic web_search and web_fetch behavior.", + ), + ConfigSectionSpec( + "diagnostics", + "Diagnostics", + "Logging and debugging flags.", + advanced=True, + ), + ConfigSectionSpec( + "smoke", + "Smoke Tests", + "Optional live smoke-test model overrides.", + advanced=True, + ), +) + + +_NON_PROVIDER_FIELDS: tuple[ConfigFieldSpec, ...] = ( + ConfigFieldSpec( + "MODEL", + "Default Model", + "models", + settings_attr="model", + default="nvidia_nim/nvidia/nemotron-3-super-120b-a12b", + description="Fallback provider/model route for all Claude model names.", + ), + ConfigFieldSpec( + "MODEL_OPUS", + "Opus Override", + "models", + settings_attr="model_opus", + description="Optional provider/model route for Opus requests.", + ), + ConfigFieldSpec( + "MODEL_SONNET", + "Sonnet Override", + "models", + settings_attr="model_sonnet", + description="Optional provider/model route for Sonnet requests.", + ), + ConfigFieldSpec( + "MODEL_HAIKU", + "Haiku Override", + "models", + settings_attr="model_haiku", + description="Optional provider/model route for Haiku requests.", + ), + ConfigFieldSpec( + "ENABLE_MODEL_THINKING", + "Enable Thinking", + "thinking", + "boolean", + settings_attr="enable_model_thinking", + default="true", + ), + ConfigFieldSpec( + "ENABLE_OPUS_THINKING", + "Opus Thinking", + "thinking", + "tri_boolean", + settings_attr="enable_opus_thinking", + description="Blank inherits Enable Thinking.", + ), + ConfigFieldSpec( + "ENABLE_SONNET_THINKING", + "Sonnet Thinking", + "thinking", + "tri_boolean", + settings_attr="enable_sonnet_thinking", + description="Blank inherits Enable Thinking.", + ), + ConfigFieldSpec( + "ENABLE_HAIKU_THINKING", + "Haiku Thinking", + "thinking", + "tri_boolean", + settings_attr="enable_haiku_thinking", + description="Blank inherits Enable Thinking.", + ), + ConfigFieldSpec( + "ANTHROPIC_AUTH_TOKEN", + "API/CLI Auth Token", + "runtime", + "secret", + settings_attr="anthropic_auth_token", + default="freecc", + secret=True, + restart_required=True, + description="Protects Claude/API access. It is not admin-page login.", + ), + ConfigFieldSpec( + "PROVIDER_RATE_LIMIT", + "Provider Rate Limit", + "runtime", + "number", + settings_attr="provider_rate_limit", + default="1", + ), + ConfigFieldSpec( + "PROVIDER_RATE_WINDOW", + "Provider Rate Window", + "runtime", + "number", + settings_attr="provider_rate_window", + default="3", + ), + ConfigFieldSpec( + "PROVIDER_MAX_CONCURRENCY", + "Provider Max Concurrency", + "runtime", + "number", + settings_attr="provider_max_concurrency", + default="5", + ), + ConfigFieldSpec( + "HTTP_READ_TIMEOUT", + "HTTP Read Timeout", + "runtime", + "number", + settings_attr="http_read_timeout", + default="300", + ), + ConfigFieldSpec( + "HTTP_WRITE_TIMEOUT", + "HTTP Write Timeout", + "runtime", + "number", + settings_attr="http_write_timeout", + default="60", + ), + ConfigFieldSpec( + "HTTP_CONNECT_TIMEOUT", + "HTTP Connect Timeout", + "runtime", + "number", + settings_attr="http_connect_timeout", + default="60", + ), + ConfigFieldSpec( + "HOST", + "Server Host", + "runtime", + settings_attr="host", + default="0.0.0.0", + restart_required=True, + ), + ConfigFieldSpec( + "PORT", + "Server Port", + "runtime", + "number", + settings_attr="port", + default="8082", + restart_required=True, + ), + ConfigFieldSpec( + "MESSAGING_PLATFORM", + "Messaging Platform", + "messaging", + "select", + settings_attr="messaging_platform", + default="discord", + options=("telegram", "discord", "none"), + session_sensitive=True, + ), + ConfigFieldSpec( + "MESSAGING_RATE_LIMIT", + "Messaging Rate Limit", + "messaging", + "number", + settings_attr="messaging_rate_limit", + default="1", + session_sensitive=True, + ), + ConfigFieldSpec( + "MESSAGING_RATE_WINDOW", + "Messaging Rate Window", + "messaging", + "number", + settings_attr="messaging_rate_window", + default="1", + session_sensitive=True, + ), + ConfigFieldSpec( + "TELEGRAM_BOT_TOKEN", + "Telegram Bot Token", + "messaging", + "secret", + settings_attr="telegram_bot_token", + secret=True, + session_sensitive=True, + ), + ConfigFieldSpec( + "ALLOWED_TELEGRAM_USER_ID", + "Allowed Telegram User ID", + "messaging", + settings_attr="allowed_telegram_user_id", + session_sensitive=True, + ), + ConfigFieldSpec( + "TELEGRAM_PROXY_URL", + "Telegram Proxy URL", + "messaging", + "secret", + settings_attr="telegram_proxy_url", + secret=True, + session_sensitive=True, + description="Optional Telegram-only proxy, e.g. socks5://127.0.0.1:1080.", + ), + ConfigFieldSpec( + "DISCORD_BOT_TOKEN", + "Discord Bot Token", + "messaging", + "secret", + settings_attr="discord_bot_token", + secret=True, + session_sensitive=True, + ), + ConfigFieldSpec( + "ALLOWED_DISCORD_CHANNELS", + "Allowed Discord Channels", + "messaging", + settings_attr="allowed_discord_channels", + session_sensitive=True, + ), + ConfigFieldSpec( + "ALLOWED_DIR", + "Allowed Directory", + "messaging", + settings_attr="allowed_dir", + session_sensitive=True, + ), + ConfigFieldSpec( + "MAX_MESSAGE_LOG_ENTRIES_PER_CHAT", + "Max Tracked Messages Per Chat", + "messaging", + "number", + settings_attr="max_message_log_entries_per_chat", + advanced=True, + session_sensitive=True, + ), + ConfigFieldSpec( + "VOICE_NOTE_ENABLED", + "Voice Notes", + "voice", + "boolean", + settings_attr="voice_note_enabled", + default="false", + session_sensitive=True, + ), + ConfigFieldSpec( + "WHISPER_DEVICE", + "Whisper Device", + "voice", + "select", + settings_attr="whisper_device", + default="nvidia_nim", + options=("cpu", "cuda", "nvidia_nim"), + session_sensitive=True, + ), + ConfigFieldSpec( + "WHISPER_MODEL", + "Whisper Model", + "voice", + settings_attr="whisper_model", + default="openai/whisper-large-v3", + session_sensitive=True, + ), + ConfigFieldSpec( + "FAST_PREFIX_DETECTION", + "Fast Prefix Detection", + "runtime", + "boolean", + settings_attr="fast_prefix_detection", + default="true", + advanced=True, + ), + ConfigFieldSpec( + "ENABLE_NETWORK_PROBE_MOCK", + "Network Probe Mock", + "runtime", + "boolean", + settings_attr="enable_network_probe_mock", + default="true", + advanced=True, + ), + ConfigFieldSpec( + "ENABLE_TITLE_GENERATION_SKIP", + "Title Generation Skip", + "runtime", + "boolean", + settings_attr="enable_title_generation_skip", + default="true", + advanced=True, + ), + ConfigFieldSpec( + "ENABLE_SUGGESTION_MODE_SKIP", + "Suggestion Mode Skip", + "runtime", + "boolean", + settings_attr="enable_suggestion_mode_skip", + default="true", + advanced=True, + ), + ConfigFieldSpec( + "ENABLE_FILEPATH_EXTRACTION_MOCK", + "Filepath Extraction Mock", + "runtime", + "boolean", + settings_attr="enable_filepath_extraction_mock", + default="true", + advanced=True, + ), + ConfigFieldSpec( + "ENABLE_WEB_SERVER_TOOLS", + "Web Server Tools", + "web_tools", + "boolean", + settings_attr="enable_web_server_tools", + default="true", + ), + ConfigFieldSpec( + "WEB_FETCH_ALLOWED_SCHEMES", + "Allowed Web Fetch Schemes", + "web_tools", + settings_attr="web_fetch_allowed_schemes", + default="http,https", + ), + ConfigFieldSpec( + "WEB_FETCH_ALLOW_PRIVATE_NETWORKS", + "Allow Private Networks", + "web_tools", + "boolean", + settings_attr="web_fetch_allow_private_networks", + default="false", + ), + ConfigFieldSpec( + "DEBUG_PLATFORM_EDITS", + "Debug Platform Edits", + "diagnostics", + "boolean", + settings_attr="debug_platform_edits", + default="false", + advanced=True, + restart_required=True, + ), + ConfigFieldSpec( + "DEBUG_SUBAGENT_STACK", + "Debug Subagent Stack", + "diagnostics", + "boolean", + settings_attr="debug_subagent_stack", + default="false", + advanced=True, + restart_required=True, + ), + ConfigFieldSpec( + "LOG_RAW_API_PAYLOADS", + "Log Raw API Payloads", + "diagnostics", + "boolean", + settings_attr="log_raw_api_payloads", + default="false", + advanced=True, + restart_required=True, + ), + ConfigFieldSpec( + "LOG_RAW_SSE_EVENTS", + "Log Raw SSE Events", + "diagnostics", + "boolean", + settings_attr="log_raw_sse_events", + default="false", + advanced=True, + ), + ConfigFieldSpec( + "LOG_API_ERROR_TRACEBACKS", + "Log API Error Tracebacks", + "diagnostics", + "boolean", + settings_attr="log_api_error_tracebacks", + default="false", + advanced=True, + restart_required=True, + ), + ConfigFieldSpec( + "LOG_RAW_MESSAGING_CONTENT", + "Log Raw Messaging Content", + "diagnostics", + "boolean", + settings_attr="log_raw_messaging_content", + default="false", + advanced=True, + restart_required=True, + ), + ConfigFieldSpec( + "LOG_RAW_CLI_DIAGNOSTICS", + "Log Raw CLI Diagnostics", + "diagnostics", + "boolean", + settings_attr="log_raw_cli_diagnostics", + default="false", + advanced=True, + restart_required=True, + ), + ConfigFieldSpec( + "LOG_MESSAGING_ERROR_DETAILS", + "Log Messaging Error Details", + "diagnostics", + "boolean", + settings_attr="log_messaging_error_details", + default="false", + advanced=True, + restart_required=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_NVIDIA_NIM", + "Smoke NVIDIA NIM Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_OPEN_ROUTER", + "Smoke OpenRouter Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_MISTRAL", + "Smoke Mistral Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_MISTRAL_CODESTRAL", + "Smoke Mistral Codestral Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_DEEPSEEK", + "Smoke DeepSeek Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_LMSTUDIO", + "Smoke LM Studio Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_LLAMACPP", + "Smoke llama.cpp Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_OLLAMA", + "Smoke Ollama Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_KIMI", + "Smoke Kimi Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_MINIMAX", + "Smoke MiniMax Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_WAFER", + "Smoke Wafer Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_OPENCODE", + "Smoke OpenCode Zen Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_OPENCODE_GO", + "Smoke OpenCode Go Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_VERCEL", + "Smoke Vercel AI Gateway Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_HUGGINGFACE", + "Smoke Hugging Face Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_COHERE", + "Smoke Cohere Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_GITHUB_MODELS", + "Smoke GitHub Models Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_ZAI", + "Smoke Z.ai Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_FIREWORKS", + "Smoke Fireworks Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_CLOUDFLARE", + "Smoke Cloudflare Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_GEMINI", + "Smoke Gemini Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_GROQ", + "Smoke Groq Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_SAMBANOVA", + "Smoke SambaNova Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_MODEL_CEREBRAS", + "Smoke Cerebras Model", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_NIM_MODELS", + "Smoke NIM Models", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_NIM_EXTRA_MODELS", + "Smoke NIM Extra Models", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_OPENROUTER_FREE_MODELS", + "Smoke OpenRouter Free Models", + "smoke", + advanced=True, + ), + ConfigFieldSpec( + "FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS", + "Smoke OpenRouter Free Extra Models", + "smoke", + advanced=True, + ), +) + + +FIELDS: tuple[ConfigFieldSpec, ...] = ( + *(ConfigFieldSpec(**spec) for spec in provider_field_specs()), + *_NON_PROVIDER_FIELDS, +) +FIELD_BY_KEY = {field.key: field for field in FIELDS} + + +def field_input_key(field: ConfigFieldSpec) -> str | None: + """Return the Settings input key used for a manifest field.""" + + if field.settings_attr is None: + return None + model_field = Settings.model_fields[field.settings_attr] + alias = model_field.validation_alias + if alias is None: + return field.settings_attr + return str(alias) + + +def env_keys() -> frozenset[str]: + """Return env keys owned by the admin manifest.""" + + return frozenset(field.key for field in FIELDS) + + +def fields_with_attrs() -> Iterable[ConfigFieldSpec]: + """Yield fields that validate through Settings.""" + + return (field for field in FIELDS if field.settings_attr is not None) diff --git a/src/free_claude_code/config/admin/persistence.py b/src/free_claude_code/config/admin/persistence.py new file mode 100644 index 0000000..0e1320d --- /dev/null +++ b/src/free_claude_code/config/admin/persistence.py @@ -0,0 +1,218 @@ +"""Managed env persistence, validation preview, and rendering.""" + +import os +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from free_claude_code.config.paths import managed_env_path +from free_claude_code.config.settings import Settings + +from .manifest import FIELD_BY_KEY, FIELDS, SECTIONS, ConfigFieldSpec +from .sources import dotenv_values_from_file, is_locked_source, template_values +from .validation import settings_from_values +from .values import MASKED_SECRET, load_value_state, normalize_for_env + + +@dataclass(frozen=True, slots=True) +class PreparedAdminUpdate: + """Validated Admin update ready for an atomic managed-file commit.""" + + target_values: dict[str, str] + settings: Settings | None + errors: tuple[str, ...] + pending_fields: tuple[str, ...] + path: Path + + @property + def valid(self) -> bool: + return self.settings is not None + + def validation_response(self) -> dict[str, Any]: + return { + "valid": self.valid, + "errors": list(self.errors), + "env_preview": render_env_file(self.target_values, mask_secrets=True), + } + + def applied_response(self) -> dict[str, Any]: + if not self.valid: + return self.validation_response() | { + "applied": False, + "pending_fields": [], + } + return { + "applied": True, + "valid": True, + "errors": [], + "env_preview": render_env_file( + self.target_values, + mask_secrets=True, + ), + "path": str(self.path), + "pending_fields": list(self.pending_fields), + } + + +def target_values_with_updates(updates: Mapping[str, Any]) -> dict[str, str]: + """Return managed env values after applying admin updates.""" + + state = load_value_state() + values = template_values() + + # Preserve existing managed values when present. If no managed config exists, + # seed the first write from effective repo values to migrate legacy setups. + managed_values = dotenv_values_from_file(managed_env_path()) + if managed_values: + values.update( + {key: val for key, val in managed_values.items() if key in values} + ) + else: + for key, entry in state.items(): + if entry["source"] in {"repo_env", "template", "default"}: + values[key] = str(entry["value"]) + + for key, value in updates.items(): + field = FIELD_BY_KEY.get(key) + if field is None: + continue + if is_locked_source(state[key]["source"]): + continue + if field.secret and value == MASKED_SECRET: + continue + values[key] = normalize_for_env(value) + + for field in FIELDS: + values.setdefault(field.key, field.default) + return values + + +def effective_values_for_validation( + target_values: Mapping[str, str], +) -> dict[str, str]: + """Return values validated after preserving locked external sources.""" + + values = dict(target_values) + for key, entry in load_value_state().items(): + if is_locked_source(entry["source"]): + values[key] = str(entry["value"]) + return values + + +def validate_updates(updates: Mapping[str, Any]) -> dict[str, Any]: + """Validate partial admin updates and return a masked generated env preview.""" + + return prepare_admin_update(updates).validation_response() + + +def changed_pending_fields( + updates: Mapping[str, Any], + *, + settings: Settings, +) -> list[str]: + """Return changed fields that require manual runtime action.""" + + state = load_value_state() + pending: list[str] = [] + for key, value in updates.items(): + field = FIELD_BY_KEY.get(key) + if field is None or is_locked_source(state[key]["source"]): + continue + if field.secret and value == MASKED_SECRET: + continue + requires_restart = field.restart_required or field.session_sensitive + if not requires_restart: + requires_restart = _active_voice_credential(settings) == key + if not requires_restart: + continue + if normalize_for_env(value) == str(state[key]["value"]): + continue + pending.append(key) + return pending + + +def _active_voice_credential(settings: Settings) -> str | None: + if not settings.voice_note_enabled: + return None + if settings.whisper_device == "nvidia_nim": + return "NVIDIA_NIM_API_KEY" + return "HUGGINGFACE_API_KEY" + + +def prepare_admin_update(updates: Mapping[str, Any]) -> PreparedAdminUpdate: + """Validate an update and construct its prospective Settings snapshot.""" + + target_values = target_values_with_updates(updates) + effective_values = effective_values_for_validation(target_values) + settings, errors = settings_from_values(effective_values) + pending_fields = ( + tuple(changed_pending_fields(updates, settings=settings)) + if settings is not None + else () + ) + return PreparedAdminUpdate( + target_values=target_values, + settings=settings, + errors=tuple(errors), + pending_fields=pending_fields, + path=managed_env_path(), + ) + + +def commit_prepared_admin_update(prepared: PreparedAdminUpdate) -> dict[str, Any]: + """Atomically persist a previously validated Admin update.""" + + if not prepared.valid: + raise ValueError("Cannot commit an invalid Admin update") + + path = prepared.path + path.parent.mkdir(parents=True, exist_ok=True) + temp_path = path.with_suffix(path.suffix + ".tmp") + try: + temp_path.write_text( + render_env_file(prepared.target_values), + encoding="utf-8", + ) + os.replace(temp_path, path) + finally: + temp_path.unlink(missing_ok=True) + return prepared.applied_response() + + +def quote_env_value(value: str) -> str: + """Quote a value when dotenv syntax requires it.""" + + if value == "": + return "" + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + if any(char.isspace() for char in value) or any( + char in value for char in ('"', "#", "=", "$") + ): + return f'"{escaped}"' + return value + + +def render_env_file(values: Mapping[str, str], *, mask_secrets: bool = False) -> str: + """Render a complete grouped env file.""" + + lines: list[str] = [ + "# Managed by Free Claude Code /admin.", + "# Edit in the server UI when possible.", + "", + ] + fields_by_section: dict[str, list[ConfigFieldSpec]] = { + section.section_id: [] for section in SECTIONS + } + for field in FIELDS: + fields_by_section.setdefault(field.section_id, []).append(field) + + for section in SECTIONS: + lines.append(f"# {section.label}") + for field in fields_by_section.get(section.section_id, []): + value = values.get(field.key, field.default) + if mask_secrets and field.secret and value: + value = MASKED_SECRET + lines.append(f"{field.key}={quote_env_value(value)}") + lines.append("") + return "\n".join(lines).rstrip() + "\n" diff --git a/src/free_claude_code/config/admin/provider_manifest.py b/src/free_claude_code/config/admin/provider_manifest.py new file mode 100644 index 0000000..0e8f739 --- /dev/null +++ b/src/free_claude_code/config/admin/provider_manifest.py @@ -0,0 +1,201 @@ +"""Catalog-derived Admin provider fields.""" + +from typing import Any + +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.config.settings import Settings + +_PROVIDER_FIELD_OVERRIDES: dict[str, dict[str, Any]] = { + "NVIDIA_NIM_API_KEY": { + "label": "NVIDIA NIM API Key", + "description": "Used by NVIDIA NIM chat and optional NIM voice transcription.", + }, + "MISTRAL_API_KEY": { + "label": "Mistral API Key", + "description": ( + "Mistral La Plateforme (api.mistral.ai); Experiment plan is free tier with rate limits." + ), + }, + "CODESTRAL_API_KEY": { + "label": "Codestral API Key", + "description": ( + "Mistral Codestral endpoint (codestral.mistral.ai); distinct from Mistral " + "La Plateforme ``MISTRAL_API_KEY``. See Mistral docs for coding/FIM domains." + ), + }, + "OPENCODE_API_KEY": { + "label": "OpenCode API Key", + "description": ( + "OpenCode Zen curated gateway (opencode.ai/zen/v1) and OpenCode Go subscription " + "gateway (opencode.ai/zen/go/v1); single key from opencode.ai/auth." + ), + }, + "AI_GATEWAY_API_KEY": { + "label": "Vercel AI Gateway API Key", + "description": ( + "Vercel AI Gateway API key for the OpenAI-compatible endpoint at " + "ai-gateway.vercel.sh/v1." + ), + }, + "HUGGINGFACE_API_KEY": { + "label": "Hugging Face API Key", + "description": ( + "Hugging Face token with Inference Providers permission; also used " + "for local Whisper model downloads when voice notes need gated models." + ), + }, + "COHERE_API_KEY": { + "label": "Cohere API Key", + "description": "Cohere API key for the OpenAI-compatible Compatibility API.", + }, + "GITHUB_MODELS_TOKEN": { + "label": "GitHub Models Token", + "description": ( + "GitHub token with Models access for the OpenAI-compatible inference API " + "at models.github.ai." + ), + }, + "ZAI_API_KEY": { + "label": "Z.ai API Key", + "description": "Z.ai Coding Plan API key.", + }, + "FIREWORKS_API_KEY": { + "label": "Fireworks API Key", + "description": "Fireworks AI inference API key.", + }, + "MINIMAX_API_KEY": { + "label": "MiniMax API Key", + "description": ( + "MiniMax API key for the OpenAI-compatible Chat Completions API at " + "free_claude_code.api.minimax.io/v1." + ), + }, + "CLOUDFLARE_API_TOKEN": { + "label": "Cloudflare API Token", + "description": ( + "Cloudflare API token for account-scoped AI REST requests. " + "Use with CLOUDFLARE_ACCOUNT_ID." + ), + }, + "GEMINI_API_KEY": { + "label": "Gemini API Key", + "description": ( + "Google AI Studio Gemini API key (Google AI Studio / Gemini API " + "[OpenAI-compatible](https://ai.google.dev/gemini-api/docs/openai)); " + "free tier has per-model rate limits and data may be used for improvement " + "outside the UK/CH/EEA/EU." + ), + }, + "GROQ_API_KEY": { + "label": "Groq API Key", + "description": ( + "GroqCloud OpenAI-compatible API key ([console.groq.com/keys](" + "https://console.groq.com/keys)); see Groq " + "[OpenAI compatibility docs](https://console.groq.com/docs/openai)." + ), + }, + "SAMBANOVA_API_KEY": { + "label": "SambaNova API Key", + "description": ( + "SambaNova Cloud OpenAI-compatible API key (create at " + "[cloud.sambanova.ai/apis](https://cloud.sambanova.ai/apis))." + ), + }, + "CEREBRAS_API_KEY": { + "label": "Cerebras API Key", + "description": ( + "Cerebras Inference API key (create in [Cloud Console](https://cloud.cerebras.ai)); " + "see [Quickstart](https://inference-docs.cerebras.ai/quickstart) and " + "[OpenAI compatibility](https://inference-docs.cerebras.ai/resources/openai)." + ), + }, +} + + +def provider_field_specs() -> tuple[dict[str, Any], ...]: + """Return provider fields generated from the provider catalog.""" + + return ( + *_credential_field_specs(), + *_cloudflare_account_field_specs(), + *_local_base_url_field_specs(), + *_proxy_field_specs(), + ) + + +def _credential_field_specs() -> tuple[dict[str, Any], ...]: + specs: list[dict[str, Any]] = [] + seen_env_keys: set[str] = set() + for descriptor in PROVIDER_CATALOG.values(): + if descriptor.credential_env is None: + continue + if descriptor.credential_env in seen_env_keys: + continue + seen_env_keys.add(descriptor.credential_env) + spec = { + "key": descriptor.credential_env, + "label": f"{descriptor.display_name} API Key", + "section_id": "providers", + "field_type": "secret", + "settings_attr": descriptor.credential_attr, + "secret": True, + } + spec.update(_PROVIDER_FIELD_OVERRIDES.get(descriptor.credential_env, {})) + specs.append(spec) + return tuple(specs) + + +def _local_base_url_field_specs() -> tuple[dict[str, Any], ...]: + specs: list[dict[str, Any]] = [] + for descriptor in PROVIDER_CATALOG.values(): + if descriptor.base_url_attr is None: + continue + specs.append( + { + "key": _settings_env_key(descriptor.base_url_attr), + "label": f"{descriptor.display_name} Base URL", + "section_id": "providers", + "settings_attr": descriptor.base_url_attr, + "default": descriptor.default_base_url or "", + } + ) + return tuple(specs) + + +def _cloudflare_account_field_specs() -> tuple[dict[str, Any], ...]: + return ( + { + "key": "CLOUDFLARE_ACCOUNT_ID", + "label": "Cloudflare Account ID", + "section_id": "providers", + "settings_attr": "cloudflare_account_id", + "description": ( + "Cloudflare account ID used to build the /accounts/{id}/ai/v1 endpoint." + ), + }, + ) + + +def _proxy_field_specs() -> tuple[dict[str, Any], ...]: + specs: list[dict[str, Any]] = [] + for descriptor in PROVIDER_CATALOG.values(): + if descriptor.proxy_attr is None: + continue + specs.append( + { + "key": _settings_env_key(descriptor.proxy_attr), + "label": f"{descriptor.display_name} Proxy", + "section_id": "providers", + "field_type": "secret", + "settings_attr": descriptor.proxy_attr, + "secret": True, + "advanced": True, + } + ) + return tuple(specs) + + +def _settings_env_key(settings_attr: str) -> str: + model_field = Settings.model_fields[settings_attr] + alias = model_field.validation_alias + return str(alias) if alias is not None else settings_attr diff --git a/src/free_claude_code/config/admin/sources.py b/src/free_claude_code/config/admin/sources.py new file mode 100644 index 0000000..854538d --- /dev/null +++ b/src/free_claude_code/config/admin/sources.py @@ -0,0 +1,84 @@ +"""Admin config source loading and source precedence.""" + +import os +from io import StringIO +from pathlib import Path +from typing import Literal + +from dotenv import dotenv_values + +from free_claude_code.config.env_files import ( + explicit_env_path as configured_explicit_env_path, +) +from free_claude_code.config.env_files import ( + repo_env_path as configured_repo_env_path, +) +from free_claude_code.config.env_files import ( + settings_env_files, +) +from free_claude_code.config.env_template import load_env_template_or_empty + +from .manifest import FIELDS + +SourceType = Literal[ + "default", + "template", + "repo_env", + "managed_env", + "explicit_env_file", + "process", +] + + +def repo_env_path() -> Path: + """Return the repo-local env path.""" + + return configured_repo_env_path() + + +def explicit_env_path() -> Path | None: + """Return the explicit FCC_ENV_FILE path, when configured.""" + + return configured_explicit_env_path(os.environ) + + +def configured_env_files() -> tuple[tuple[SourceType, Path], ...]: + """Return dotenv files in low-to-high precedence order.""" + + source_names: tuple[SourceType, ...] = ( + "repo_env", + "managed_env", + "explicit_env_file", + ) + return tuple(zip(source_names, settings_env_files(), strict=False)) + + +def dotenv_values_from_text(text: str) -> dict[str, str]: + """Parse dotenv text into string values.""" + + values = dotenv_values(stream=StringIO(text)) + return {key: "" if value is None else value for key, value in values.items()} + + +def template_values() -> dict[str, str]: + """Return .env.example values plus manifest defaults for newer fields.""" + + values = dotenv_values_from_text(load_env_template_or_empty()) + for field in FIELDS: + values.setdefault(field.key, field.default) + return values + + +def dotenv_values_from_file(path: Path) -> dict[str, str]: + """Return dotenv values from a file, or an empty mapping when absent.""" + + if not path.is_file(): + return {} + values = dotenv_values(path) + return {key: "" if value is None else value for key, value in values.items()} + + +def is_locked_source(source: SourceType) -> bool: + """Return whether an admin value source must not be overwritten.""" + + return source in {"process", "explicit_env_file"} diff --git a/src/free_claude_code/config/admin/status.py b/src/free_claude_code/config/admin/status.py new file mode 100644 index 0000000..dbb0fd6 --- /dev/null +++ b/src/free_claude_code/config/admin/status.py @@ -0,0 +1,54 @@ +"""Provider configuration status for the Admin UI.""" + +from collections.abc import Mapping +from typing import Any + +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG + +from .manifest import FIELDS + + +def provider_config_status( + state: Mapping[str, Mapping[str, Any]], +) -> list[dict[str, Any]]: + """Return provider configuration status without making network calls.""" + statuses: list[dict[str, Any]] = [] + for provider_id, descriptor in PROVIDER_CATALOG.items(): + if descriptor.local: + base_url = "" + if descriptor.base_url_attr is not None: + base_url = _value_for_settings_attr(state, descriptor.base_url_attr) + statuses.append( + { + "provider_id": provider_id, + "display_name": descriptor.display_name, + "kind": "local", + "status": "missing_url" if not base_url.strip() else "unknown", + "label": "Missing URL" if not base_url.strip() else "Not checked", + "base_url": base_url or descriptor.default_base_url or "", + } + ) + continue + + value = str(state.get(descriptor.credential_env, {}).get("value", "")) + configured = bool(value.strip()) + statuses.append( + { + "provider_id": provider_id, + "display_name": descriptor.display_name, + "kind": "remote", + "status": "configured" if configured else "missing_key", + "label": "Configured" if configured else "Missing key", + "credential_env": descriptor.credential_env, + } + ) + return statuses + + +def _value_for_settings_attr( + state: Mapping[str, Mapping[str, Any]], settings_attr: str +) -> str: + for field in FIELDS: + if field.settings_attr == settings_attr: + return str(state.get(field.key, {}).get("value", field.default)) + return "" diff --git a/src/free_claude_code/config/admin/validation.py b/src/free_claude_code/config/admin/validation.py new file mode 100644 index 0000000..c37c5d5 --- /dev/null +++ b/src/free_claude_code/config/admin/validation.py @@ -0,0 +1,46 @@ +"""Settings-backed Admin config validation.""" + +from collections.abc import Mapping +from typing import Any + +from pydantic import ValidationError + +from free_claude_code.config.settings import Settings + +from .manifest import FIELDS, field_input_key + + +def validate_values(values: Mapping[str, str]) -> tuple[bool, list[str]]: + """Validate proposed env values against the Settings model.""" + + settings, errors = settings_from_values(values) + return settings is not None, errors + + +def settings_from_values( + values: Mapping[str, str], +) -> tuple[Settings | None, list[str]]: + """Build the prospective Settings snapshot without reading dotenv files.""" + + kwargs: dict[str, Any] = {"_env_file": None} + for field in FIELDS: + input_key = field_input_key(field) + if input_key is None: + continue + kwargs[input_key] = values.get(field.key, "") + + try: + return Settings(**kwargs), [] + except ValidationError as exc: + return None, format_validation_errors(exc) + + +def format_validation_errors(exc: ValidationError) -> list[str]: + """Return user-readable validation errors from a Pydantic exception.""" + + errors: list[str] = [] + for error in exc.errors(): + loc = ".".join(str(part) for part in error.get("loc", ())) + message = str(error.get("msg", "Invalid value")) + errors.append(f"{loc}: {message}" if loc else message) + return errors diff --git a/src/free_claude_code/config/admin/values.py b/src/free_claude_code/config/admin/values.py new file mode 100644 index 0000000..2781894 --- /dev/null +++ b/src/free_claude_code/config/admin/values.py @@ -0,0 +1,113 @@ +"""Admin config value state and API response assembly.""" + +import os +from typing import Any + +from free_claude_code.config.paths import managed_env_path + +from .manifest import FIELD_BY_KEY, FIELDS, SECTIONS, ConfigFieldSpec +from .sources import ( + configured_env_files, + dotenv_values_from_file, + explicit_env_path, + is_locked_source, + repo_env_path, + template_values, +) +from .status import provider_config_status + +MASKED_SECRET = "********" +ValueState = dict[str, dict[str, Any]] + + +def normalize_for_env(value: Any) -> str: + """Normalize a submitted admin value for dotenv persistence.""" + + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def display_value(field: ConfigFieldSpec, value: str) -> str: + """Return the Admin UI display value for a raw config value.""" + + if field.secret and value: + return MASKED_SECRET + return value + + +def load_value_state() -> ValueState: + """Load effective admin field values and their sources.""" + + values = template_values() + sources = {key: "template" if key in values else "default" for key in FIELD_BY_KEY} + + for source, path in configured_env_files(): + file_values = dotenv_values_from_file(path) + for key, value in file_values.items(): + if key in FIELD_BY_KEY: + values[key] = value + sources[key] = source + + for key in FIELD_BY_KEY: + if key in os.environ: + values[key] = os.environ[key] + sources[key] = "process" + + return { + key: { + "value": values.get(key, ""), + "source": sources.get(key, "default"), + } + for key in FIELD_BY_KEY + } + + +def load_config_response() -> dict[str, Any]: + """Return manifest and current config values for the admin UI.""" + + state = load_value_state() + fields: list[dict[str, Any]] = [] + for field in FIELDS: + entry = state[field.key] + source = entry["source"] + raw_value = entry["value"] + fields.append( + { + "key": field.key, + "label": field.label, + "section": field.section_id, + "type": field.field_type, + "value": display_value(field, raw_value), + "configured": bool(str(raw_value).strip()), + "source": source, + "locked": is_locked_source(source), + "secret": field.secret, + "advanced": field.advanced, + "restart_required": field.restart_required, + "session_sensitive": field.session_sensitive, + "options": list(field.options), + "description": field.description, + } + ) + + return { + "sections": [ + { + "id": section.section_id, + "label": section.label, + "description": section.description, + "advanced": section.advanced, + } + for section in SECTIONS + ], + "fields": fields, + "paths": { + "managed": str(managed_env_path()), + "repo": str(repo_env_path()), + "explicit": str(explicit_env_path()) if explicit_env_path() else None, + }, + "provider_status": provider_config_status(state), + } diff --git a/src/free_claude_code/config/constants.py b/src/free_claude_code/config/constants.py new file mode 100644 index 0000000..c4ea854 --- /dev/null +++ b/src/free_claude_code/config/constants.py @@ -0,0 +1,7 @@ +"""Shared defaults used by config models and provider adapters.""" + +# HTTP client connect timeout (seconds). Keep aligned with README.md and .env.example. +HTTP_CONNECT_TIMEOUT_DEFAULT = 10.0 + +# Anthropic Messages API default when the client omits max_tokens. +ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 81920 diff --git a/src/free_claude_code/config/env_files.py b/src/free_claude_code/config/env_files.py new file mode 100644 index 0000000..c683021 --- /dev/null +++ b/src/free_claude_code/config/env_files.py @@ -0,0 +1,91 @@ +"""Dotenv file discovery and explicit dotenv override helpers.""" + +import os +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from dotenv import dotenv_values + +from .paths import managed_env_path + +ANTHROPIC_AUTH_TOKEN_ENV = "ANTHROPIC_AUTH_TOKEN" + + +def repo_env_path() -> Path: + """Return the repo-local env path.""" + + return Path(".env") + + +def explicit_env_path(env: Mapping[str, str] | None = None) -> Path | None: + """Return the explicit FCC_ENV_FILE path, when configured.""" + + source = env if env is not None else os.environ + if explicit := source.get("FCC_ENV_FILE"): + return Path(explicit) + return None + + +def settings_env_files(env: Mapping[str, str] | None = None) -> tuple[Path, ...]: + """Return Settings dotenv files in low-to-high precedence order.""" + + files: list[Path] = [ + repo_env_path(), + managed_env_path(), + ] + if explicit := explicit_env_path(env): + files.append(explicit) + return tuple(files) + + +def configured_env_files(model_config: Mapping[str, Any]) -> tuple[Path, ...]: + """Return the env files currently configured for a Settings model.""" + + configured = model_config.get("env_file") + if configured is None: + return () + if isinstance(configured, (str, Path)): + return (Path(configured),) + return tuple(Path(item) for item in configured) + + +def env_file_value(path: Path, key: str) -> str | None: + """Return a dotenv value when the file explicitly defines the key.""" + + if not path.is_file(): + return None + + try: + values = dotenv_values(path) + except OSError: + return None + + if key not in values: + return None + value = values[key] + return "" if value is None else value + + +def env_file_override(model_config: Mapping[str, Any], key: str) -> str | None: + """Return the last configured dotenv value that explicitly defines a key.""" + + configured_value: str | None = None + for env_file in configured_env_files(model_config): + value = env_file_value(env_file, key) + if value is not None: + configured_value = value + return configured_value + + +def process_env_key_is_effective( + model_config: Mapping[str, Any], + key: str, + env: Mapping[str, str] | None = None, +) -> bool: + """Return whether a key is coming from process env instead of configured dotenv.""" + + source = env if env is not None else os.environ + if env_file_override(model_config, key) is not None: + return False + return key in source diff --git a/src/free_claude_code/config/env_migrations.py b/src/free_claude_code/config/env_migrations.py new file mode 100644 index 0000000..91279b9 --- /dev/null +++ b/src/free_claude_code/config/env_migrations.py @@ -0,0 +1,129 @@ +"""One-time dotenv key migrations for FCC-owned config files.""" + +import re +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path + +from .env_files import explicit_env_path, repo_env_path +from .paths import managed_env_path + +LEGACY_HUGGINGFACE_TOKEN_ENV = "HF_TOKEN" +HUGGINGFACE_API_KEY_ENV = "HUGGINGFACE_API_KEY" + +_DOTENV_ASSIGNMENT_RE = re.compile( + r"^(?P\s*(?:export\s+)?)(?P[A-Za-z_][A-Za-z0-9_]*)(?P\s*(?:=|$))" +) + + +@dataclass(frozen=True, slots=True) +class EnvKeyMigration: + """A dotenv key rename migration.""" + + old_key: str + new_key: str + + +HUGGINGFACE_TOKEN_MIGRATION = EnvKeyMigration( + old_key=LEGACY_HUGGINGFACE_TOKEN_ENV, + new_key=HUGGINGFACE_API_KEY_ENV, +) + + +def migrate_owned_env_files() -> tuple[Path, ...]: + """Apply key migrations to repo and managed dotenv files.""" + + return tuple( + path.resolve() + for path in _unique_paths((repo_env_path(), managed_env_path())) + if migrate_env_key_in_file(path, HUGGINGFACE_TOKEN_MIGRATION) + ) + + +def explicit_env_file_huggingface_warning( + env: Mapping[str, str] | None = None, +) -> str | None: + """Return a warning when an explicit env file still uses ``HF_TOKEN``.""" + + path = explicit_env_path(env) + if path is None or not path.is_file(): + return None + text = path.read_text(encoding="utf-8") + if not env_text_needs_migration(text, HUGGINGFACE_TOKEN_MIGRATION): + return None + return ( + f"{LEGACY_HUGGINGFACE_TOKEN_ENV} is set in explicit FCC_ENV_FILE {path}. " + f"Rename it to {HUGGINGFACE_API_KEY_ENV}; explicit env files are not " + "rewritten automatically." + ) + + +def migrate_env_key_in_file(path: Path, migration: EnvKeyMigration) -> bool: + """Rename a dotenv key in ``path`` when the new key is absent.""" + + if not path.is_file(): + return False + original = path.read_text(encoding="utf-8") + migrated, changed = migrate_env_key_in_text(original, migration) + if not changed: + return False + path.write_text(migrated, encoding="utf-8") + return True + + +def migrate_env_key_in_text( + text: str, + migration: EnvKeyMigration, +) -> tuple[str, bool]: + """Return text with ``old_key`` renamed to ``new_key`` when safe.""" + + if _defines_key(text, migration.new_key): + return text, False + + lines = text.splitlines(keepends=True) + changed = False + for index, line in enumerate(lines): + match = _DOTENV_ASSIGNMENT_RE.match(line) + if match is None or match.group("key") != migration.old_key: + continue + lines[index] = ( + f"{match.group('prefix')}{migration.new_key}{match.group('suffix')}" + f"{line[match.end() :]}" + ) + changed = True + if not changed: + return text, False + return "".join(lines), True + + +def env_text_needs_migration(text: str, migration: EnvKeyMigration) -> bool: + """Return whether text defines old key without new key.""" + + return _defines_key(text, migration.old_key) and not _defines_key( + text, migration.new_key + ) + + +def _defines_key(text: str, key: str) -> bool: + for line in text.splitlines(): + if line.lstrip().startswith("#"): + continue + match = _DOTENV_ASSIGNMENT_RE.match(line) + if match is not None and match.group("key") == key: + return True + return False + + +def _unique_paths(paths: tuple[Path, ...]) -> tuple[Path, ...]: + seen: set[Path] = set() + unique: list[Path] = [] + for path in paths: + try: + resolved = path.resolve() + except OSError: + resolved = path + if resolved in seen: + continue + seen.add(resolved) + unique.append(path) + return tuple(unique) diff --git a/src/free_claude_code/config/env_template.py b/src/free_claude_code/config/env_template.py new file mode 100644 index 0000000..77845e9 --- /dev/null +++ b/src/free_claude_code/config/env_template.py @@ -0,0 +1,29 @@ +"""Canonical env template loading for init and Admin UI defaults.""" + +import importlib.resources +from pathlib import Path + + +def load_env_template() -> str: + """Load the root ``.env.example`` template from wheel resources or checkout.""" + + packaged = importlib.resources.files("free_claude_code.config").joinpath( + "env.example" + ) + if packaged.is_file(): + return packaged.read_text("utf-8") + + source_template = Path(__file__).resolve().parents[3] / ".env.example" + if source_template.is_file(): + return source_template.read_text(encoding="utf-8") + + raise FileNotFoundError("Could not find bundled or source .env.example template.") + + +def load_env_template_or_empty() -> str: + """Return the env template, or an empty template when unavailable.""" + + try: + return load_env_template() + except FileNotFoundError: + return "" diff --git a/src/free_claude_code/config/logging_config.py b/src/free_claude_code/config/logging_config.py new file mode 100644 index 0000000..dd100fa --- /dev/null +++ b/src/free_claude_code/config/logging_config.py @@ -0,0 +1,159 @@ +"""Loguru-based structured logging configuration. + +Structured logs are written as JSON lines to a configurable path (default +``logs/server.log``). Stdlib logging is intercepted and funneled to loguru. +Context vars (request_id, node_id, chat_id) from contextualize() are +included at top level for easy grep/filter. +""" + +import json +import logging +import re +import threading +from pathlib import Path + +from loguru import logger + +_configured = False + +# Loguru ``logger.bind()`` key used by structured TRACE payloads; ``core/trace.py`` +# uses the identical string constant ``TRACE_PAYLOAD_BINDING``. +_TRACE_PAYLOAD_BINDING = "trace_payload" + +# Context keys we promote to top-level JSON for traceability / grep +_CONTEXT_KEYS = ( + "request_id", + "node_id", + "chat_id", + "claude_session_id", + "http_method", + "http_path", +) + +_TELEGRAM_BOT_RE = re.compile( + r"(https?://api\.telegram\.org/)bot([0-9]+:[A-Za-z0-9_-]+)(/?)", + re.IGNORECASE, +) +# Authorization: Bearer (HTTP client / proxy debug lines) +_AUTH_BEARER_RE = re.compile( + r"(\bAuthorization\s*:\s*Bearer\s+)([^\s'\"]+)", + re.IGNORECASE, +) + + +def _redact_sensitive_substrings(message: str) -> str: + """Remove obvious API tokens and secrets before JSON log line emission.""" + text = _TELEGRAM_BOT_RE.sub(r"\1bot\3", message) + return _AUTH_BEARER_RE.sub(r"\1", text) + + +def _serialize_with_context(record) -> str: + """Format record as JSON with context vars at top level. + Returns a format template; we inject _json into record for output. + """ + extra = record.get("extra", {}) + out = { + "time": str(record["time"]), + "level": record["level"].name, + "message": _redact_sensitive_substrings(str(record["message"])), + "module": record["name"], + "function": record["function"], + "line": record["line"], + } + trace_payload = extra.get(_TRACE_PAYLOAD_BINDING) + for key in _CONTEXT_KEYS: + if key in extra and extra[key] is not None: + out[key] = extra[key] + if isinstance(trace_payload, dict): + for tk, tv in trace_payload.items(): + if tk in out: + continue + out[tk] = tv + out["trace"] = True + record["_json"] = json.dumps(out, default=str) + return "{_json}\n" + + +class InterceptHandler(logging.Handler): + """Redirect stdlib logging to loguru.""" + + def __init__(self) -> None: + super().__init__() + self._local = threading.local() + + def emit(self, record: logging.LogRecord) -> None: + if getattr(self._local, "active", False): + # Avoid deadlock when nested stdlib records fire during a loguru emit. + return + self._local.active = True + try: + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + frame, depth = logging.currentframe(), 2 + while frame is not None and frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + logger.opt(depth=depth, exception=record.exc_info).log( + level, record.getMessage() + ) + finally: + self._local.active = False + + +def configure_logging( + log_file: str | Path, *, force: bool = False, verbose_third_party: bool = False +) -> None: + """Configure loguru with JSON output to log_file and intercept stdlib logging. + + Idempotent: skips if already configured (e.g. hot reload). + Use force=True to reconfigure (e.g. in tests with a different log path). + + When ``verbose_third_party`` is false, noisy HTTP and Telegram loggers are capped + at WARNING unless explicitly configured otherwise. + """ + global _configured + if _configured and not force: + return + _configured = True + + # Remove default loguru handler (writes to stderr) + logger.remove() + + log_path = Path(log_file) + log_path.parent.mkdir(parents=True, exist_ok=True) + + # Truncate log file on fresh start for clean debugging + log_path.write_text("") + + # Add file sink: JSON lines, DEBUG level, context vars at top level + logger.add( + log_file, + level="DEBUG", + format=_serialize_with_context, + encoding="utf-8", + mode="a", + rotation="50 MB", + enqueue=True, + ) + + # Intercept stdlib logging: route all root logger output to loguru + intercept = InterceptHandler() + logging.root.handlers = [intercept] + logging.root.setLevel(logging.DEBUG) + + third_party = ( + "httpx", + "httpcore", + "httpcore.http11", + "httpcore.connection", + "telegram", + "telegram.ext", + ) + for name in third_party: + logging.getLogger(name).setLevel( + logging.WARNING if not verbose_third_party else logging.NOTSET + ) diff --git a/src/free_claude_code/config/model_refs.py b/src/free_claude_code/config/model_refs.py new file mode 100644 index 0000000..57dc269 --- /dev/null +++ b/src/free_claude_code/config/model_refs.py @@ -0,0 +1,61 @@ +"""Provider-prefixed model reference helpers.""" + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True, slots=True) +class ConfiguredChatModelRef: + """A unique configured chat model reference and the env keys that set it.""" + + model_ref: str + provider_id: str + model_id: str + sources: tuple[str, ...] + + +class ChatModelConfig(Protocol): + model: str + model_opus: str | None + model_sonnet: str | None + model_haiku: str | None + + +def parse_provider_type(model_ref: str) -> str: + """Extract provider type from any 'provider/model' string.""" + + return model_ref.split("/", 1)[0] + + +def parse_model_name(model_ref: str) -> str: + """Extract model name from any 'provider/model' string.""" + + return model_ref.split("/", 1)[1] + + +def configured_chat_model_refs( + settings: ChatModelConfig, +) -> tuple[ConfiguredChatModelRef, ...]: + """Return unique configured chat provider/model refs with source env keys.""" + + candidates = ( + ("MODEL", settings.model), + ("MODEL_OPUS", settings.model_opus), + ("MODEL_SONNET", settings.model_sonnet), + ("MODEL_HAIKU", settings.model_haiku), + ) + sources_by_ref: dict[str, list[str]] = {} + for source, model_ref in candidates: + if model_ref is None: + continue + sources_by_ref.setdefault(model_ref, []).append(source) + + return tuple( + ConfiguredChatModelRef( + model_ref=model_ref, + provider_id=parse_provider_type(model_ref), + model_id=parse_model_name(model_ref), + sources=tuple(sources), + ) + for model_ref, sources in sources_by_ref.items() + ) diff --git a/src/free_claude_code/config/nim.py b/src/free_claude_code/config/nim.py new file mode 100644 index 0000000..fd2fdf3 --- /dev/null +++ b/src/free_claude_code/config/nim.py @@ -0,0 +1,118 @@ +"""NVIDIA NIM settings (fixed values, no env config).""" + +from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator + +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + +class NimSettings(BaseModel): + """Fixed NVIDIA NIM settings (not configurable via env).""" + + temperature: float = Field( + 1.0, ge=0.0, le=2.0, description="Sampling temperature, must be >=0 and <=2." + ) + top_p: float = Field( + 1.0, ge=0.0, le=1.0, description="Nucleus sampling probability. [0,1]" + ) + top_k: int = -1 + max_tokens: int = Field( + ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ge=1, + description="Maximum number of tokens in output.", + ) + presence_penalty: float = Field(0.0, ge=-2.0, le=2.0) + frequency_penalty: float = Field(0.0, ge=-2.0, le=2.0) + min_p: float = Field( + 0.0, ge=0.0, le=1.0, description="Minimum probability threshold [0,1]." + ) + repetition_penalty: float = Field( + 1.0, ge=0.0, description="Penalty for repeated tokens. Must be >=0." + ) + seed: int | None = None + stop: str | None = None + parallel_tool_calls: bool = True + ignore_eos: bool = False + min_tokens: int = Field(0, ge=0, description="Minimum tokens in the response.") + chat_template: str | None = None + request_id: str | None = None + + model_config = ConfigDict(extra="forbid") + + @field_validator("top_k", mode="before") + @classmethod + def validate_top_k(cls, v, info: ValidationInfo): + if v is None or v == "": + return -1 + int_v = int(v) + if int_v < -1: + raise ValueError(f"{info.field_name} must be -1 or >= 0") + return int_v + + @field_validator( + "temperature", + "top_p", + "min_p", + "presence_penalty", + "frequency_penalty", + "repetition_penalty", + mode="before", + ) + @classmethod + def validate_float_fields(cls, v, info: ValidationInfo): + field_defaults = { + "temperature": 1.0, + "top_p": 1.0, + "min_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0, + "repetition_penalty": 1.0, + } + if v is None or v == "": + key = info.field_name or "temperature" + return field_defaults.get(key, 1.0) + try: + val = float(v) + except (TypeError, ValueError) as err: + raise ValueError( + f"{info.field_name} must be a float. Got {type(v).__name__}." + ) from err + return val + + @field_validator("max_tokens", "min_tokens", mode="before") + @classmethod + def validate_int_fields(cls, v, info: ValidationInfo): + field_defaults = { + "max_tokens": ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + "min_tokens": 0, + } + if v is None or v == "": + key = info.field_name or "max_tokens" + return field_defaults.get(key, ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS) + try: + val = int(v) + except (TypeError, ValueError) as err: + raise ValueError( + f"{info.field_name} must be an int. Got {type(v).__name__}." + ) from err + return val + + @field_validator("seed", mode="before") + @classmethod + def parse_optional_int(cls, v, info: ValidationInfo): + if v == "" or v is None: + return None + try: + return int(v) + except (TypeError, ValueError) as err: + raise ValueError( + f"{info.field_name} must be an int or empty/None." + ) from err + + @field_validator("stop", "chat_template", "request_id", mode="before") + @classmethod + def parse_optional_str(cls, v, info: ValidationInfo): + if v == "": + return None + if v is not None and not isinstance(v, str): + return str(v) + return v diff --git a/src/free_claude_code/config/paths.py b/src/free_claude_code/config/paths.py new file mode 100644 index 0000000..980a4c0 --- /dev/null +++ b/src/free_claude_code/config/paths.py @@ -0,0 +1,52 @@ +"""Shared filesystem paths for Free Claude Code configuration.""" + +from pathlib import Path + +FCC_CONFIG_DIRNAME = ".fcc" +FCC_ENV_FILENAME = ".env" +LEGACY_REPO_DIRNAME = "free-claude-code" +LEGACY_XDG_CONFIG_DIRNAME = ".config" +MESSAGING_STATE_DIRNAME = "agent_workspace" +FCC_LOGS_DIRNAME = "logs" +SERVER_LOG_FILENAME = "server.log" +CODEX_MODEL_CATALOG_FILENAME = "codex-model-catalog.json" + + +def config_dir_path() -> Path: + """Return the default user config directory.""" + + return Path.home() / FCC_CONFIG_DIRNAME + + +def managed_env_path() -> Path: + """Return the default user-managed env file path.""" + + return config_dir_path() / FCC_ENV_FILENAME + + +def legacy_env_paths() -> tuple[Path, ...]: + """Return legacy user env paths that can be migrated to ~/.fcc/.env.""" + + home = Path.home() + return ( + home / LEGACY_REPO_DIRNAME / FCC_ENV_FILENAME, + home / LEGACY_XDG_CONFIG_DIRNAME / LEGACY_REPO_DIRNAME / FCC_ENV_FILENAME, + ) + + +def messaging_state_dir_path() -> Path: + """Return the managed messaging state directory.""" + + return config_dir_path() / MESSAGING_STATE_DIRNAME + + +def server_log_path() -> Path: + """Return the canonical server log path.""" + + return config_dir_path() / FCC_LOGS_DIRNAME / SERVER_LOG_FILENAME + + +def codex_model_catalog_path() -> Path: + """Return the generated Codex model catalog path.""" + + return config_dir_path() / CODEX_MODEL_CATALOG_FILENAME diff --git a/src/free_claude_code/config/provider_catalog.py b/src/free_claude_code/config/provider_catalog.py new file mode 100644 index 0000000..bb4f927 --- /dev/null +++ b/src/free_claude_code/config/provider_catalog.py @@ -0,0 +1,284 @@ +"""Neutral provider catalog: IDs, credentials, defaults, proxy and capability metadata. + +Adapter factories live in :mod:`providers.runtime.factory`; this module stays free of +provider implementation imports (see contract tests). +""" + +from dataclasses import dataclass + +# Default upstream base URLs are owned here with the provider catalog. +NVIDIA_NIM_DEFAULT_BASE = "https://integrate.api.nvidia.com/v1" +# Moonshot Kimi OpenAI-compatible Chat Completions API. +KIMI_DEFAULT_BASE = "https://api.moonshot.ai/v1" +WAFER_DEFAULT_BASE = "https://pass.wafer.ai/v1" +MINIMAX_DEFAULT_BASE = "https://api.minimax.io/v1" +# DeepSeek Chat Completions API; cache usage is reported on this endpoint. +DEEPSEEK_DEFAULT_BASE = "https://api.deepseek.com" +FIREWORKS_DEFAULT_BASE = "https://api.fireworks.ai/inference/v1" +# Cloudflare account-scoped AI REST root; provider appends /accounts/{id}/ai/v1. +CLOUDFLARE_AI_REST_ROOT = "https://api.cloudflare.com/client/v4" +OPENROUTER_DEFAULT_BASE = "https://openrouter.ai/api/v1" +MISTRAL_DEFAULT_BASE = "https://api.mistral.ai/v1" +# Codestral IDE/personal endpoint (distinct from La Plateforme ``api.mistral.ai`` keys). +CODESTRAL_DEFAULT_BASE = "https://codestral.mistral.ai/v1" +LMSTUDIO_DEFAULT_BASE = "http://localhost:1234/v1" +LLAMACPP_DEFAULT_BASE = "http://localhost:8080/v1" +OLLAMA_DEFAULT_BASE = "http://localhost:11434" +OPENCODE_DEFAULT_BASE = "https://opencode.ai/zen/v1" +OPENCODE_GO_DEFAULT_BASE = "https://opencode.ai/zen/go/v1" +VERCEL_AI_GATEWAY_DEFAULT_BASE = "https://ai-gateway.vercel.sh/v1" +HUGGINGFACE_DEFAULT_BASE = "https://router.huggingface.co/v1" +COHERE_DEFAULT_BASE = "https://api.cohere.ai/compatibility/v1" +GITHUB_MODELS_DEFAULT_BASE = "https://models.github.ai/inference" +# Z.ai GLM Coding Plan OpenAI-compatible Chat Completions API. +ZAI_DEFAULT_BASE = "https://api.z.ai/api/coding/paas/v4" +# Google AI Studio Gemini API OpenAI-compat layer (not Vertex AI). +GEMINI_DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta/openai/" +GROQ_DEFAULT_BASE = "https://api.groq.com/openai/v1" +CEREBRAS_DEFAULT_BASE = "https://api.cerebras.ai/v1" +SAMBANOVA_DEFAULT_BASE = "https://api.sambanova.ai/v1" + + +@dataclass(frozen=True, slots=True) +class ProviderDescriptor: + """Metadata for building :class:`~providers.base.ProviderConfig` and factory wiring.""" + + provider_id: str + display_name: str + local: bool = False + credential_env: str | None = None + credential_url: str | None = None + credential_attr: str | None = None + static_credential: str | None = None + default_base_url: str | None = None + base_url_attr: str | None = None + proxy_attr: str | None = None + + +PROVIDER_CATALOG: dict[str, ProviderDescriptor] = { + "nvidia_nim": ProviderDescriptor( + provider_id="nvidia_nim", + display_name="NVIDIA NIM", + credential_env="NVIDIA_NIM_API_KEY", + credential_url="https://build.nvidia.com/settings/api-keys", + credential_attr="nvidia_nim_api_key", + default_base_url=NVIDIA_NIM_DEFAULT_BASE, + proxy_attr="nvidia_nim_proxy", + ), + "open_router": ProviderDescriptor( + provider_id="open_router", + display_name="OpenRouter", + credential_env="OPENROUTER_API_KEY", + credential_url="https://openrouter.ai/keys", + credential_attr="open_router_api_key", + default_base_url=OPENROUTER_DEFAULT_BASE, + proxy_attr="open_router_proxy", + ), + "gemini": ProviderDescriptor( + provider_id="gemini", + display_name="Gemini", + credential_env="GEMINI_API_KEY", + credential_url="https://aistudio.google.com/apikey", + credential_attr="gemini_api_key", + default_base_url=GEMINI_DEFAULT_BASE, + proxy_attr="gemini_proxy", + ), + "deepseek": ProviderDescriptor( + provider_id="deepseek", + display_name="DeepSeek", + credential_env="DEEPSEEK_API_KEY", + credential_url="https://platform.deepseek.com/api_keys", + credential_attr="deepseek_api_key", + default_base_url=DEEPSEEK_DEFAULT_BASE, + ), + "mistral": ProviderDescriptor( + provider_id="mistral", + display_name="Mistral", + credential_env="MISTRAL_API_KEY", + credential_url="https://console.mistral.ai/", + credential_attr="mistral_api_key", + default_base_url=MISTRAL_DEFAULT_BASE, + proxy_attr="mistral_proxy", + ), + "mistral_codestral": ProviderDescriptor( + provider_id="mistral_codestral", + display_name="Mistral Codestral", + credential_env="CODESTRAL_API_KEY", + credential_url="https://console.mistral.ai/", + credential_attr="codestral_api_key", + default_base_url=CODESTRAL_DEFAULT_BASE, + proxy_attr="codestral_proxy", + ), + "opencode": ProviderDescriptor( + provider_id="opencode", + display_name="OpenCode Zen", + credential_env="OPENCODE_API_KEY", + credential_url="https://opencode.ai/auth", + credential_attr="opencode_api_key", + default_base_url=OPENCODE_DEFAULT_BASE, + proxy_attr="opencode_proxy", + ), + "opencode_go": ProviderDescriptor( + provider_id="opencode_go", + display_name="OpenCode Go", + credential_env="OPENCODE_API_KEY", + credential_url="https://opencode.ai/auth", + credential_attr="opencode_api_key", + default_base_url=OPENCODE_GO_DEFAULT_BASE, + proxy_attr="opencode_go_proxy", + ), + "vercel": ProviderDescriptor( + provider_id="vercel", + display_name="Vercel AI Gateway", + credential_env="AI_GATEWAY_API_KEY", + credential_url="https://vercel.com/docs/ai-gateway", + credential_attr="vercel_ai_gateway_api_key", + default_base_url=VERCEL_AI_GATEWAY_DEFAULT_BASE, + proxy_attr="vercel_ai_gateway_proxy", + ), + "huggingface": ProviderDescriptor( + provider_id="huggingface", + display_name="Hugging Face", + credential_env="HUGGINGFACE_API_KEY", + credential_url="https://huggingface.co/settings/tokens", + credential_attr="huggingface_api_key", + default_base_url=HUGGINGFACE_DEFAULT_BASE, + proxy_attr="huggingface_proxy", + ), + "cohere": ProviderDescriptor( + provider_id="cohere", + display_name="Cohere", + credential_env="COHERE_API_KEY", + credential_url="https://dashboard.cohere.com/api-keys", + credential_attr="cohere_api_key", + default_base_url=COHERE_DEFAULT_BASE, + proxy_attr="cohere_proxy", + ), + "github_models": ProviderDescriptor( + provider_id="github_models", + display_name="GitHub Models", + credential_env="GITHUB_MODELS_TOKEN", + credential_url="https://github.com/settings/tokens", + credential_attr="github_models_token", + default_base_url=GITHUB_MODELS_DEFAULT_BASE, + proxy_attr="github_models_proxy", + ), + "wafer": ProviderDescriptor( + provider_id="wafer", + display_name="Wafer", + credential_env="WAFER_API_KEY", + credential_url="https://www.wafer.ai/pass", + credential_attr="wafer_api_key", + default_base_url=WAFER_DEFAULT_BASE, + proxy_attr="wafer_proxy", + ), + "kimi": ProviderDescriptor( + provider_id="kimi", + display_name="Kimi", + credential_env="KIMI_API_KEY", + credential_url="https://platform.moonshot.cn/console/api-keys", + credential_attr="kimi_api_key", + default_base_url=KIMI_DEFAULT_BASE, + proxy_attr="kimi_proxy", + ), + "minimax": ProviderDescriptor( + provider_id="minimax", + display_name="MiniMax", + credential_env="MINIMAX_API_KEY", + credential_url="https://platform.minimax.io/user-center/basic-information/interface-key", + credential_attr="minimax_api_key", + default_base_url=MINIMAX_DEFAULT_BASE, + proxy_attr="minimax_proxy", + ), + "cerebras": ProviderDescriptor( + provider_id="cerebras", + display_name="Cerebras", + credential_env="CEREBRAS_API_KEY", + credential_url="https://cloud.cerebras.ai", + credential_attr="cerebras_api_key", + default_base_url=CEREBRAS_DEFAULT_BASE, + proxy_attr="cerebras_proxy", + ), + "groq": ProviderDescriptor( + provider_id="groq", + display_name="Groq", + credential_env="GROQ_API_KEY", + credential_url="https://console.groq.com/keys", + credential_attr="groq_api_key", + default_base_url=GROQ_DEFAULT_BASE, + proxy_attr="groq_proxy", + ), + "sambanova": ProviderDescriptor( + provider_id="sambanova", + display_name="SambaNova", + credential_env="SAMBANOVA_API_KEY", + credential_url="https://cloud.sambanova.ai/apis", + credential_attr="sambanova_api_key", + default_base_url=SAMBANOVA_DEFAULT_BASE, + proxy_attr="sambanova_proxy", + ), + "fireworks": ProviderDescriptor( + provider_id="fireworks", + display_name="Fireworks", + credential_env="FIREWORKS_API_KEY", + credential_url="https://fireworks.ai/account/api-keys", + credential_attr="fireworks_api_key", + default_base_url=FIREWORKS_DEFAULT_BASE, + proxy_attr="fireworks_proxy", + ), + "cloudflare": ProviderDescriptor( + provider_id="cloudflare", + display_name="Cloudflare", + credential_env="CLOUDFLARE_API_TOKEN", + credential_url="https://dash.cloudflare.com/profile/api-tokens", + credential_attr="cloudflare_api_token", + default_base_url=CLOUDFLARE_AI_REST_ROOT, + proxy_attr="cloudflare_proxy", + ), + "zai": ProviderDescriptor( + provider_id="zai", + display_name="Z.ai", + credential_env="ZAI_API_KEY", + credential_attr="zai_api_key", + default_base_url=ZAI_DEFAULT_BASE, + proxy_attr="zai_proxy", + ), + "lmstudio": ProviderDescriptor( + provider_id="lmstudio", + display_name="LM Studio", + static_credential="lm-studio", + default_base_url=LMSTUDIO_DEFAULT_BASE, + base_url_attr="lm_studio_base_url", + proxy_attr="lmstudio_proxy", + local=True, + ), + "llamacpp": ProviderDescriptor( + provider_id="llamacpp", + display_name="llama.cpp", + static_credential="llamacpp", + default_base_url=LLAMACPP_DEFAULT_BASE, + base_url_attr="llamacpp_base_url", + proxy_attr="llamacpp_proxy", + local=True, + ), + "ollama": ProviderDescriptor( + provider_id="ollama", + display_name="Ollama", + static_credential="ollama", + default_base_url=OLLAMA_DEFAULT_BASE, + base_url_attr="ollama_base_url", + local=True, + ), +} + +# Key order: +# NVIDIA NIM first (README default), DeepSeek fourth, OpenCode gateways adjacent, +# Vercel / Hugging Face / Cohere / GitHub Models follow gateway-style remotes, +# then cloud gateways and local providers per project plan +# (github.com/cheahjs/free-llm-api-resources Free Providers TOC as rough guide +# beyond fixed slots). +# ``SUPPORTED_PROVIDER_IDS`` inherits this insertion order for UI and error-message listing. +SUPPORTED_PROVIDER_IDS: tuple[str, ...] = tuple(PROVIDER_CATALOG.keys()) + +if len(set(SUPPORTED_PROVIDER_IDS)) != len(SUPPORTED_PROVIDER_IDS): + raise AssertionError("Duplicate provider ids in PROVIDER_CATALOG key order") diff --git a/src/free_claude_code/config/server_urls.py b/src/free_claude_code/config/server_urls.py new file mode 100644 index 0000000..c541438 --- /dev/null +++ b/src/free_claude_code/config/server_urls.py @@ -0,0 +1,26 @@ +"""Browser-friendly local server URLs shared by runtime and launchers.""" + +from free_claude_code.config.settings import Settings + + +def _browser_host_for_local_urls(settings: Settings) -> str: + """Host fragment for URLs shown to humans on the same machine as the server.""" + + host = settings.host.strip() if settings.host else "127.0.0.1" + if host in {"0.0.0.0", "::", "[::]"}: + host = "127.0.0.1" + if ":" in host and not host.startswith("["): + host = f"[{host}]" + return host + + +def local_proxy_root_url(settings: Settings) -> str: + """Return the proxy root URL (no path) for clients on the same machine.""" + + return f"http://{_browser_host_for_local_urls(settings)}:{settings.port}" + + +def local_admin_url(settings: Settings) -> str: + """Return a browser-friendly URL for the localhost-only admin UI.""" + + return f"{local_proxy_root_url(settings)}/admin" diff --git a/src/free_claude_code/config/settings.py b/src/free_claude_code/config/settings.py new file mode 100644 index 0000000..718349d --- /dev/null +++ b/src/free_claude_code/config/settings.py @@ -0,0 +1,403 @@ +"""Flat application settings schema loaded by Pydantic Settings.""" + +from functools import lru_cache +from typing import Any + +from pydantic import Field, field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from .constants import HTTP_CONNECT_TIMEOUT_DEFAULT +from .env_files import ( + ANTHROPIC_AUTH_TOKEN_ENV, + env_file_override, + settings_env_files, +) +from .nim import NimSettings +from .provider_catalog import SUPPORTED_PROVIDER_IDS + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + # ==================== OpenRouter Config ==================== + open_router_api_key: str = Field(default="", validation_alias="OPENROUTER_API_KEY") + + # ==================== Mistral La Plateforme ==================== + mistral_api_key: str = Field(default="", validation_alias="MISTRAL_API_KEY") + + # ==================== Mistral Codestral (codestral.mistral.ai) ==================== + codestral_api_key: str = Field(default="", validation_alias="CODESTRAL_API_KEY") + + # ==================== DeepSeek Config ==================== + deepseek_api_key: str = Field(default="", validation_alias="DEEPSEEK_API_KEY") + + # ==================== Kimi Config ==================== + kimi_api_key: str = Field(default="", validation_alias="KIMI_API_KEY") + + # ==================== Wafer Config ==================== + wafer_api_key: str = Field(default="", validation_alias="WAFER_API_KEY") + + # ==================== MiniMax Config ==================== + minimax_api_key: str = Field(default="", validation_alias="MINIMAX_API_KEY") + + # ==================== OpenCode Zen / OpenCode Go ==================== + # Same key from opencode.ai/auth; zen uses prefix ``opencode/``, Go uses ``opencode_go/``. + opencode_api_key: str = Field(default="", validation_alias="OPENCODE_API_KEY") + + # ==================== Vercel AI Gateway ==================== + vercel_ai_gateway_api_key: str = Field( + default="", validation_alias="AI_GATEWAY_API_KEY" + ) + + # ==================== Hugging Face Inference Providers ==================== + huggingface_api_key: str = Field(default="", validation_alias="HUGGINGFACE_API_KEY") + + # ==================== Cohere Compatibility API ==================== + cohere_api_key: str = Field(default="", validation_alias="COHERE_API_KEY") + + # ==================== GitHub Models ==================== + github_models_token: str = Field(default="", validation_alias="GITHUB_MODELS_TOKEN") + + # ==================== SambaNova Cloud ==================== + sambanova_api_key: str = Field(default="", validation_alias="SAMBANOVA_API_KEY") + + # ==================== Z.ai Config ==================== + zai_api_key: str = Field(default="", validation_alias="ZAI_API_KEY") + + # ==================== Fireworks AI Config ==================== + fireworks_api_key: str = Field(default="", validation_alias="FIREWORKS_API_KEY") + + # ==================== Cloudflare Workers AI Config ==================== + cloudflare_api_token: str = Field( + default="", validation_alias="CLOUDFLARE_API_TOKEN" + ) + cloudflare_account_id: str = Field( + default="", validation_alias="CLOUDFLARE_ACCOUNT_ID" + ) + + # ==================== Google Gemini (Google AI Studio) ==================== + gemini_api_key: str = Field(default="", validation_alias="GEMINI_API_KEY") + + # ==================== Groq (OpenAI-compatible) ==================== + groq_api_key: str = Field(default="", validation_alias="GROQ_API_KEY") + + # ==================== Cerebras Inference (OpenAI-compatible) ==================== + cerebras_api_key: str = Field(default="", validation_alias="CEREBRAS_API_KEY") + + # ==================== Messaging Platform Selection ==================== + # Valid: "telegram" | "discord" | "none" + messaging_platform: str = Field( + default="discord", validation_alias="MESSAGING_PLATFORM" + ) + messaging_rate_limit: int = Field( + default=1, validation_alias="MESSAGING_RATE_LIMIT" + ) + messaging_rate_window: float = Field( + default=1.0, validation_alias="MESSAGING_RATE_WINDOW" + ) + + # ==================== NVIDIA NIM Config ==================== + nvidia_nim_api_key: str = "" + + # ==================== LM Studio Config ==================== + lm_studio_base_url: str = Field( + default="http://localhost:1234/v1", + validation_alias="LM_STUDIO_BASE_URL", + ) + + # ==================== Llama.cpp Config ==================== + llamacpp_base_url: str = Field( + default="http://localhost:8080/v1", + validation_alias="LLAMACPP_BASE_URL", + ) + + # ==================== Ollama Config ==================== + ollama_base_url: str = Field( + default="http://localhost:11434", + validation_alias="OLLAMA_BASE_URL", + ) + + # ==================== Model ==================== + # All Claude model requests are mapped to this single model (fallback) + # Format: provider_type/model/name + model: str = "nvidia_nim/nvidia/nemotron-3-super-120b-a12b" + + # Per-model overrides (optional, falls back to MODEL) + # Each can use a different provider + model_opus: str | None = Field(default=None, validation_alias="MODEL_OPUS") + model_sonnet: str | None = Field(default=None, validation_alias="MODEL_SONNET") + model_haiku: str | None = Field(default=None, validation_alias="MODEL_HAIKU") + + # ==================== Per-Provider Proxy ==================== + nvidia_nim_proxy: str = Field(default="", validation_alias="NVIDIA_NIM_PROXY") + open_router_proxy: str = Field(default="", validation_alias="OPENROUTER_PROXY") + mistral_proxy: str = Field(default="", validation_alias="MISTRAL_PROXY") + codestral_proxy: str = Field(default="", validation_alias="CODESTRAL_PROXY") + lmstudio_proxy: str = Field(default="", validation_alias="LMSTUDIO_PROXY") + llamacpp_proxy: str = Field(default="", validation_alias="LLAMACPP_PROXY") + kimi_proxy: str = Field(default="", validation_alias="KIMI_PROXY") + wafer_proxy: str = Field(default="", validation_alias="WAFER_PROXY") + minimax_proxy: str = Field(default="", validation_alias="MINIMAX_PROXY") + opencode_proxy: str = Field(default="", validation_alias="OPENCODE_PROXY") + opencode_go_proxy: str = Field(default="", validation_alias="OPENCODE_GO_PROXY") + vercel_ai_gateway_proxy: str = Field( + default="", validation_alias="VERCEL_AI_GATEWAY_PROXY" + ) + huggingface_proxy: str = Field(default="", validation_alias="HUGGINGFACE_PROXY") + cohere_proxy: str = Field(default="", validation_alias="COHERE_PROXY") + github_models_proxy: str = Field(default="", validation_alias="GITHUB_MODELS_PROXY") + sambanova_proxy: str = Field(default="", validation_alias="SAMBANOVA_PROXY") + zai_proxy: str = Field(default="", validation_alias="ZAI_PROXY") + fireworks_proxy: str = Field(default="", validation_alias="FIREWORKS_PROXY") + cloudflare_proxy: str = Field(default="", validation_alias="CLOUDFLARE_PROXY") + gemini_proxy: str = Field(default="", validation_alias="GEMINI_PROXY") + groq_proxy: str = Field(default="", validation_alias="GROQ_PROXY") + cerebras_proxy: str = Field(default="", validation_alias="CEREBRAS_PROXY") + + # ==================== Provider Rate Limiting ==================== + provider_rate_limit: int = Field(default=40, validation_alias="PROVIDER_RATE_LIMIT") + provider_rate_window: int = Field( + default=60, validation_alias="PROVIDER_RATE_WINDOW" + ) + provider_max_concurrency: int = Field( + default=5, validation_alias="PROVIDER_MAX_CONCURRENCY" + ) + enable_model_thinking: bool = Field( + default=True, validation_alias="ENABLE_MODEL_THINKING" + ) + enable_opus_thinking: bool | None = Field( + default=None, validation_alias="ENABLE_OPUS_THINKING" + ) + enable_sonnet_thinking: bool | None = Field( + default=None, validation_alias="ENABLE_SONNET_THINKING" + ) + enable_haiku_thinking: bool | None = Field( + default=None, validation_alias="ENABLE_HAIKU_THINKING" + ) + + # ==================== HTTP Client Timeouts ==================== + http_read_timeout: float = Field( + default=120.0, validation_alias="HTTP_READ_TIMEOUT" + ) + http_write_timeout: float = Field( + default=10.0, validation_alias="HTTP_WRITE_TIMEOUT" + ) + http_connect_timeout: float = Field( + default=HTTP_CONNECT_TIMEOUT_DEFAULT, + validation_alias="HTTP_CONNECT_TIMEOUT", + ) + + # ==================== Fast Prefix Detection ==================== + fast_prefix_detection: bool = True + + # ==================== Optimizations ==================== + enable_network_probe_mock: bool = True + enable_title_generation_skip: bool = True + enable_suggestion_mode_skip: bool = True + enable_filepath_extraction_mock: bool = True + + # ==================== Local web server tools (web_search / web_fetch) ==================== + # Off by default: these tools perform outbound HTTP from the proxy (SSRF risk). + enable_web_server_tools: bool = Field( + default=False, validation_alias="ENABLE_WEB_SERVER_TOOLS" + ) + # Comma-separated URL schemes allowed for web_fetch (default: http,https). + web_fetch_allowed_schemes: str = Field( + default="http,https", validation_alias="WEB_FETCH_ALLOWED_SCHEMES" + ) + # When true, skip private/loopback/link-local IP blocking for web_fetch (lab only). + web_fetch_allow_private_networks: bool = Field( + default=False, validation_alias="WEB_FETCH_ALLOW_PRIVATE_NETWORKS" + ) + + # ==================== Debug / diagnostic logging (avoid sensitive content) ==================== + # When false (default), API and SSE helpers log only metadata (counts, lengths, ids). + log_raw_api_payloads: bool = Field( + default=False, validation_alias="LOG_RAW_API_PAYLOADS" + ) + log_raw_sse_events: bool = Field( + default=False, validation_alias="LOG_RAW_SSE_EVENTS" + ) + # When false (default), unhandled exceptions log only type + route metadata (no message/traceback). + log_api_error_tracebacks: bool = Field( + default=False, validation_alias="LOG_API_ERROR_TRACEBACKS" + ) + # When false (default), messaging logs omit text/transcription previews (metadata only). + log_raw_messaging_content: bool = Field( + default=False, validation_alias="LOG_RAW_MESSAGING_CONTENT" + ) + # When true, log full Claude CLI stderr, non-JSON lines, and parser error text. + log_raw_cli_diagnostics: bool = Field( + default=False, validation_alias="LOG_RAW_CLI_DIAGNOSTICS" + ) + # When true, log exception text / CLI error strings in messaging (may leak user content). + log_messaging_error_details: bool = Field( + default=False, validation_alias="LOG_MESSAGING_ERROR_DETAILS" + ) + debug_platform_edits: bool = Field( + default=False, validation_alias="DEBUG_PLATFORM_EDITS" + ) + debug_subagent_stack: bool = Field( + default=False, validation_alias="DEBUG_SUBAGENT_STACK" + ) + + # ==================== NIM Settings ==================== + nim: NimSettings = Field(default_factory=NimSettings) + + # ==================== Voice Note Transcription ==================== + voice_note_enabled: bool = Field( + default=True, validation_alias="VOICE_NOTE_ENABLED" + ) + # Device: "cpu" | "cuda" | "nvidia_nim" + # - "cpu"/"cuda": local Whisper (requires voice_local extra: uv sync --extra voice_local) + # - "nvidia_nim": NVIDIA NIM Whisper API (requires voice extra: uv sync --extra voice) + whisper_device: str = Field(default="cpu", validation_alias="WHISPER_DEVICE") + # Whisper model ID or short name (for local Whisper) or NVIDIA NIM model (for nvidia_nim) + # Local Whisper: "tiny", "base", "small", "medium", "large-v2", "large-v3", "large-v3-turbo" + # NVIDIA NIM: "nvidia/parakeet-ctc-1.1b-asr", "openai/whisper-large-v3", etc. + whisper_model: str = Field(default="base", validation_alias="WHISPER_MODEL") + # ==================== Bot Wrapper Config ==================== + telegram_bot_token: str | None = None + allowed_telegram_user_id: str | None = None + telegram_proxy_url: str = Field(default="", validation_alias="TELEGRAM_PROXY_URL") + discord_bot_token: str | None = Field( + default=None, validation_alias="DISCORD_BOT_TOKEN" + ) + allowed_discord_channels: str | None = Field( + default=None, validation_alias="ALLOWED_DISCORD_CHANNELS" + ) + allowed_dir: str = "" + max_message_log_entries_per_chat: int | None = Field( + default=None, validation_alias="MAX_MESSAGE_LOG_ENTRIES_PER_CHAT" + ) + + # ==================== Server ==================== + host: str = "0.0.0.0" + port: int = 8082 + # Optional server API key to protect endpoints (Anthropic-style) + # Set via env `ANTHROPIC_AUTH_TOKEN`. When empty, no auth is required. + anthropic_auth_token: str = Field( + default="", validation_alias="ANTHROPIC_AUTH_TOKEN" + ) + + # Handle empty strings for optional string fields + @field_validator( + "telegram_bot_token", + "allowed_telegram_user_id", + "discord_bot_token", + "allowed_discord_channels", + "model_opus", + "model_sonnet", + "model_haiku", + "enable_opus_thinking", + "enable_sonnet_thinking", + "enable_haiku_thinking", + mode="before", + ) + @classmethod + def parse_optional_str(cls, v: Any) -> Any: + if v == "": + return None + return v + + @field_validator("max_message_log_entries_per_chat", mode="before") + @classmethod + def parse_optional_log_cap(cls, v: Any) -> Any: + if v == "" or v is None: + return None + return v + + @field_validator("whisper_device") + @classmethod + def validate_whisper_device(cls, v: str) -> str: + if v not in ("cpu", "cuda", "nvidia_nim"): + raise ValueError( + f"whisper_device must be 'cpu', 'cuda', or 'nvidia_nim', got {v!r}" + ) + return v + + @field_validator("messaging_platform") + @classmethod + def validate_messaging_platform(cls, v: str) -> str: + if v not in ("telegram", "discord", "none"): + raise ValueError( + f"messaging_platform must be 'telegram', 'discord', or 'none', got {v!r}" + ) + return v + + @field_validator("messaging_rate_limit") + @classmethod + def validate_messaging_rate_limit(cls, v: int) -> int: + if v <= 0: + raise ValueError("messaging_rate_limit must be > 0") + return v + + @field_validator("messaging_rate_window") + @classmethod + def validate_messaging_rate_window(cls, v: float) -> float: + if v <= 0: + raise ValueError("messaging_rate_window must be > 0") + return float(v) + + @field_validator("web_fetch_allowed_schemes") + @classmethod + def validate_web_fetch_allowed_schemes(cls, v: str) -> str: + schemes = [part.strip().lower() for part in v.split(",") if part.strip()] + if not schemes: + raise ValueError("web_fetch_allowed_schemes must list at least one scheme") + for scheme in schemes: + if not scheme.isascii() or not scheme.isalpha(): + raise ValueError( + f"Invalid URL scheme in web_fetch_allowed_schemes: {scheme!r}" + ) + return ",".join(schemes) + + @field_validator("model", "model_opus", "model_sonnet", "model_haiku") + @classmethod + def validate_model_format(cls, v: str | None) -> str | None: + if v is None: + return None + if "/" not in v: + raise ValueError( + f"Model must be prefixed with provider type. " + f"Valid providers: {', '.join(SUPPORTED_PROVIDER_IDS)}. " + f"Format: provider_type/model/name" + ) + provider = v.split("/", 1)[0] + if provider not in SUPPORTED_PROVIDER_IDS: + supported = ", ".join(f"'{p}'" for p in SUPPORTED_PROVIDER_IDS) + raise ValueError(f"Invalid provider: '{provider}'. Supported: {supported}") + return v + + @model_validator(mode="after") + def check_nvidia_nim_api_key(self) -> Settings: + if ( + self.voice_note_enabled + and self.whisper_device == "nvidia_nim" + and not self.nvidia_nim_api_key.strip() + ): + raise ValueError( + "NVIDIA_NIM_API_KEY is required when WHISPER_DEVICE is 'nvidia_nim'. " + "Set it in your .env file." + ) + return self + + @model_validator(mode="after") + def prefer_dotenv_anthropic_auth_token(self) -> Settings: + """Let explicit .env auth config override stale shell/client tokens.""" + dotenv_value = env_file_override(self.model_config, ANTHROPIC_AUTH_TOKEN_ENV) + if dotenv_value is not None: + self.anthropic_auth_token = dotenv_value + return self + + model_config = SettingsConfigDict( + env_file=settings_env_files(), + env_file_encoding="utf-8", + extra="ignore", + ) + + +@lru_cache +def get_settings() -> Settings: + """Get cached settings instance.""" + return Settings() diff --git a/src/free_claude_code/core/__init__.py b/src/free_claude_code/core/__init__.py new file mode 100644 index 0000000..716557d --- /dev/null +++ b/src/free_claude_code/core/__init__.py @@ -0,0 +1 @@ +"""Neutral shared application core.""" diff --git a/src/free_claude_code/core/anthropic/__init__.py b/src/free_claude_code/core/anthropic/__init__.py new file mode 100644 index 0000000..699254a --- /dev/null +++ b/src/free_claude_code/core/anthropic/__init__.py @@ -0,0 +1,100 @@ +"""Anthropic protocol helpers shared across API, providers, and integrations.""" + +from .content import extract_text_from_content, get_block_attr, get_block_type +from .conversion import ( + AnthropicToOpenAIConverter, + OpenAIConversionError, + ReasoningReplayMode, + build_base_request_body, +) +from .errors import ( + anthropic_error_payload, + anthropic_error_type_for_failure, + anthropic_failure_payload, + anthropic_status_for_error_type, +) +from .models import ( + ContentBlockDocument, + ContentBlockImage, + ContentBlockRedactedThinking, + ContentBlockServerToolUse, + ContentBlockText, + ContentBlockThinking, + ContentBlockToolResult, + ContentBlockToolUse, + ContentBlockWebFetchToolResult, + ContentBlockWebSearchToolResult, + Message, + MessagesRequest, + MessagesResponse, + Role, + SystemContent, + ThinkingConfig, + TokenCountRequest, + TokenCountResponse, + Tool, + Usage, +) +from .request_serialization import dump_messages_request, serialize_tool_result_content +from .request_snapshot import anthropic_request_snapshot +from .sse_aggregation import aggregate_anthropic_sse_to_message +from .streaming import ( + AnthropicStreamLedger, + StreamBlockLedger, + ToolBlockState, + format_sse_event, + map_stop_reason, +) +from .thinking import ContentChunk, ContentType, ThinkTagParser +from .tokens import get_token_count +from .tools import HeuristicToolParser +from .utils import set_if_not_none + +__all__ = [ + "AnthropicStreamLedger", + "AnthropicToOpenAIConverter", + "ContentBlockDocument", + "ContentBlockImage", + "ContentBlockRedactedThinking", + "ContentBlockServerToolUse", + "ContentBlockText", + "ContentBlockThinking", + "ContentBlockToolResult", + "ContentBlockToolUse", + "ContentBlockWebFetchToolResult", + "ContentBlockWebSearchToolResult", + "ContentChunk", + "ContentType", + "HeuristicToolParser", + "Message", + "MessagesRequest", + "MessagesResponse", + "OpenAIConversionError", + "ReasoningReplayMode", + "Role", + "StreamBlockLedger", + "SystemContent", + "ThinkTagParser", + "ThinkingConfig", + "TokenCountRequest", + "TokenCountResponse", + "Tool", + "ToolBlockState", + "Usage", + "aggregate_anthropic_sse_to_message", + "anthropic_error_payload", + "anthropic_error_type_for_failure", + "anthropic_failure_payload", + "anthropic_request_snapshot", + "anthropic_status_for_error_type", + "build_base_request_body", + "dump_messages_request", + "extract_text_from_content", + "format_sse_event", + "get_block_attr", + "get_block_type", + "get_token_count", + "map_stop_reason", + "serialize_tool_result_content", + "set_if_not_none", +] diff --git a/src/free_claude_code/core/anthropic/content.py b/src/free_claude_code/core/anthropic/content.py new file mode 100644 index 0000000..e16aaaf --- /dev/null +++ b/src/free_claude_code/core/anthropic/content.py @@ -0,0 +1,31 @@ +"""Content block helpers for Anthropic-compatible payloads.""" + +from typing import Any + + +def get_block_attr(block: Any, attr: str, default: Any = None) -> Any: + """Get an attribute from a Pydantic model, lightweight object, or dict.""" + if hasattr(block, attr): + return getattr(block, attr) + if isinstance(block, dict): + return block.get(attr, default) + return default + + +def get_block_type(block: Any) -> str | None: + """Return a content block type when present.""" + return get_block_attr(block, "type") + + +def extract_text_from_content(content: Any) -> str: + """Extract concatenated text from message content.""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for block in content: + text = get_block_attr(block, "text", "") + if isinstance(text, str) and text: + parts.append(text) + return "".join(parts) + return "" diff --git a/src/free_claude_code/core/anthropic/conversion.py b/src/free_claude_code/core/anthropic/conversion.py new file mode 100644 index 0000000..53ad699 --- /dev/null +++ b/src/free_claude_code/core/anthropic/conversion.py @@ -0,0 +1,604 @@ +"""Message and tool format converters.""" + +import json +from copy import deepcopy +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + +from .content import get_block_attr, get_block_type +from .models import MessagesRequest +from .request_serialization import serialize_tool_result_content +from .utils import set_if_not_none + + +class OpenAIConversionError(Exception): + """Raised when Anthropic content cannot be converted to OpenAI chat without data loss.""" + + +class ReasoningReplayMode(StrEnum): + """How assistant reasoning history is replayed to OpenAI-compatible providers.""" + + DISABLED = "disabled" + THINK_TAGS = "think_tags" + REASONING_CONTENT = "reasoning_content" + + +def _openai_reject_native_only_top_level_fields( + request_data: MessagesRequest, +) -> None: + """OpenAI chat providers may only convert known top-level request fields. + + First-class model fields (e.g. ``context_management``) are not forwarded to + the OpenAI API but are allowed so clients do not hit spurious 400s. + Unknown extra keys (``__pydantic_extra__``) are still rejected. + """ + extra = request_data.model_extra + if not extra: + return + raise OpenAIConversionError( + "OpenAI chat conversion does not support these top-level request fields: " + f"{sorted(str(k) for k in extra)}. Remove the unsupported fields." + ) + + +def _tool_input_schema(tool: Any) -> dict[str, Any]: + schema = getattr(tool, "input_schema", None) + if isinstance(schema, dict): + return schema + return {"type": "object", "properties": {}} + + +def _clean_reasoning_content(value: Any) -> str | None: + if not isinstance(value, str): + return None + return value + + +def _think_tag_content(reasoning: str) -> str: + return f"\n{reasoning}\n" + + +def _tool_call_from_tool_use(block: Any) -> dict[str, Any]: + tool_input = get_block_attr(block, "input", {}) + tool_call: dict[str, Any] = { + "id": get_block_attr(block, "id"), + "type": "function", + "function": { + "name": get_block_attr(block, "name"), + "arguments": json.dumps(tool_input) + if isinstance(tool_input, dict) + else str(tool_input), + }, + } + extra_content = get_block_attr(block, "extra_content", None) + if isinstance(extra_content, dict) and extra_content: + tool_call["extra_content"] = deepcopy(extra_content) + return tool_call + + +@dataclass +class _PlainSegment: + messages: list[dict[str, Any]] + + +@dataclass +class _ToolTurnSegment: + assistant_message: dict[str, Any] + required_tool_ids: list[str] + deferred_blocks: list[Any] = field(default_factory=list) + top_level_reasoning: str | None = None + reasoning_replay: ReasoningReplayMode = ReasoningReplayMode.THINK_TAGS + assistant_emitted: bool = False + + +_TranscriptSegment = _PlainSegment | _ToolTurnSegment + + +def _tool_call_ids(tool_calls: list[dict[str, Any]]) -> list[str]: + ids: list[str] = [] + for tool_call in tool_calls: + tool_id = tool_call.get("id") + if tool_id is not None and str(tool_id).strip() != "": + ids.append(str(tool_id)) + return ids + + +def _index_first_tool_use(blocks: list[Any]) -> int | None: + for i, block in enumerate(blocks): + if get_block_type(block) == "tool_use": + return i + return None + + +def _iter_tool_uses_in_order(blocks: list[Any]) -> list[dict[str, Any]]: + return [ + _tool_call_from_tool_use(block) + for block in blocks + if get_block_type(block) == "tool_use" + ] + + +def _deferred_post_tool_blocks( + content: list[Any], *, first_tool_index: int +) -> list[Any]: + return [ + b + for i, b in enumerate(content) + if i > first_tool_index and get_block_type(b) != "tool_use" + ] + + +def _assert_no_forbidden_assistant_block(block: Any) -> None: + block_type = get_block_type(block) + if block_type == "image": + raise OpenAIConversionError( + "Assistant image blocks are not supported for OpenAI chat conversion." + ) + if block_type in ( + "server_tool_use", + "web_search_tool_result", + "web_fetch_tool_result", + ): + raise OpenAIConversionError( + "OpenAI chat conversion does not support Anthropic server tool blocks " + f"({block_type!r} in an assistant message). Remove the unsupported block." + ) + + +class _OpenAIChatHistoryLedger: + """Assemble OpenAI chat history while respecting tool-result dependencies.""" + + def __init__(self) -> None: + self._output: list[dict[str, Any]] = [] + self._segments: list[_TranscriptSegment] = [] + self._tool_results: dict[str, dict[str, Any]] = {} + + def add_plain(self, messages: list[dict[str, Any]]) -> None: + if messages: + self._segments.append(_PlainSegment(messages)) + self._drain_ready_segments() + + def add_tool_turn(self, segment: _ToolTurnSegment) -> None: + self._segments.append(segment) + self._drain_ready_segments() + + def add_user_blocks(self, blocks: list[Any]) -> None: + text_blocks: list[Any] = [] + for block in blocks: + block_type = get_block_type(block) + if block_type == "tool_result": + self._add_text_blocks(text_blocks) + self._record_tool_result(block) + else: + text_blocks.append(block) + self._add_text_blocks(text_blocks) + self._drain_ready_segments() + + def finish(self) -> list[dict[str, Any]]: + self._drain_ready_segments() + missing = self._missing_required_tool_ids() + if missing: + raise OpenAIConversionError( + "OpenAI chat conversion cannot replay incomplete tool history; " + f"missing tool_result blocks for tool_use ids: {missing}" + ) + while self._segments: + segment = self._segments.pop(0) + if isinstance(segment, _PlainSegment): + self._output.extend(segment.messages) + continue + self._emit_tool_turn(segment) + return self._output + + def _add_text_blocks(self, blocks: list[Any]) -> None: + if not blocks: + return + self.add_plain(AnthropicToOpenAIConverter._convert_user_message(blocks)) + blocks.clear() + + def _record_tool_result(self, block: Any) -> None: + tuid = get_block_attr(block, "tool_use_id") + tuid_s = str(tuid) if tuid is not None else "" + if not tuid_s: + self.add_plain(AnthropicToOpenAIConverter._convert_user_message([block])) + return + tool_content = get_block_attr(block, "content", "") + serialized = serialize_tool_result_content(tool_content) + tool_message = { + "role": "tool", + "tool_call_id": tuid, + "content": serialized if serialized else "", + } + if self._has_pending_tool_id(tuid_s): + self._tool_results[tuid_s] = tool_message + else: + self.add_plain([tool_message]) + + def _drain_ready_segments(self) -> None: + while self._segments: + segment = self._segments[0] + if isinstance(segment, _PlainSegment): + self._output.extend(segment.messages) + self._segments.pop(0) + continue + + if not segment.assistant_emitted: + self._output.append(segment.assistant_message) + segment.assistant_emitted = True + + missing = [ + tool_id + for tool_id in segment.required_tool_ids + if tool_id not in self._tool_results + ] + if missing: + break + + self._segments.pop(0) + for tool_id in segment.required_tool_ids: + self._output.append(self._tool_results.pop(tool_id)) + deferred_messages = ( + AnthropicToOpenAIConverter._deferred_post_tool_to_messages(segment) + ) + self._output.extend(deferred_messages) + + def _emit_tool_turn(self, segment: _ToolTurnSegment) -> None: + if not segment.assistant_emitted: + self._output.append(segment.assistant_message) + segment.assistant_emitted = True + for tool_id in segment.required_tool_ids: + tool_result = self._tool_results.pop(tool_id, None) + if tool_result is not None: + self._output.append(tool_result) + self._output.extend( + AnthropicToOpenAIConverter._deferred_post_tool_to_messages(segment) + ) + + def _missing_required_tool_ids(self) -> list[str]: + missing: list[str] = [] + for segment in self._segments: + if not isinstance(segment, _ToolTurnSegment): + continue + missing.extend( + tool_id + for tool_id in segment.required_tool_ids + if tool_id not in self._tool_results + ) + return missing + + def _has_pending_tool_id(self, tool_id: str) -> bool: + return any( + isinstance(segment, _ToolTurnSegment) + and tool_id in segment.required_tool_ids + for segment in self._segments + ) + + +class AnthropicToOpenAIConverter: + """Convert Anthropic message format to OpenAI-compatible format.""" + + @staticmethod + def convert_messages( + messages: list[Any], + *, + reasoning_replay: ReasoningReplayMode = ReasoningReplayMode.THINK_TAGS, + ) -> list[dict[str, Any]]: + ledger = _OpenAIChatHistoryLedger() + + for msg in messages: + role = msg.role + content = msg.content + reasoning_content = _clean_reasoning_content( + getattr(msg, "reasoning_content", None) + ) + + if role == "user" and isinstance(content, list): + ledger.add_user_blocks(content) + continue + + segments = AnthropicToOpenAIConverter._convert_message_to_segments( + role, + content, + reasoning_content=reasoning_content, + reasoning_replay=reasoning_replay, + ) + for segment in segments: + if isinstance(segment, _PlainSegment): + ledger.add_plain(segment.messages) + else: + ledger.add_tool_turn(segment) + + return ledger.finish() + + @staticmethod + def _convert_message_to_segments( + role: str, + content: Any, + *, + reasoning_content: str | None, + reasoning_replay: ReasoningReplayMode, + ) -> list[_TranscriptSegment]: + if role == "assistant" and isinstance(content, list): + if (first_i := _index_first_tool_use(content)) is not None: + for block in content: + if get_block_type(block) == "tool_use": + continue + _assert_no_forbidden_assistant_block(block) + return [ + AnthropicToOpenAIConverter._convert_assistant_message_with_split( + content, + first_tool_index=first_i, + reasoning_content=reasoning_content, + reasoning_replay=reasoning_replay, + ) + ] + for block in content: + _assert_no_forbidden_assistant_block(block) + return [ + _PlainSegment( + AnthropicToOpenAIConverter._convert_assistant_message( + content, + reasoning_content=reasoning_content, + reasoning_replay=reasoning_replay, + ) + ) + ] + if role == "user" and isinstance(content, list): + return [ + _PlainSegment(AnthropicToOpenAIConverter._convert_user_message(content)) + ] + if isinstance(content, str): + converted = {"role": role, "content": content} + if role == "assistant" and reasoning_content is not None: + if reasoning_replay == ReasoningReplayMode.REASONING_CONTENT: + converted["reasoning_content"] = reasoning_content + elif ( + reasoning_replay == ReasoningReplayMode.THINK_TAGS + and reasoning_content + ): + content_parts = [_think_tag_content(reasoning_content)] + if content: + content_parts.append(content) + converted["content"] = "\n\n".join(content_parts) + return [_PlainSegment([converted])] + if isinstance(content, list): + return [] + return [_PlainSegment([{"role": role, "content": str(content)}])] + + @staticmethod + def _convert_assistant_message_with_split( + content: list[Any], + *, + first_tool_index: int, + reasoning_content: str | None, + reasoning_replay: ReasoningReplayMode, + ) -> _ToolTurnSegment: + pre = content[:first_tool_index] + tool_calls = _iter_tool_uses_in_order(content) + if not tool_calls: + return _ToolTurnSegment( + assistant_message=AnthropicToOpenAIConverter._convert_assistant_message( + content, + reasoning_content=reasoning_content, + reasoning_replay=reasoning_replay, + )[0], + required_tool_ids=[], + ) + deferred_blocks = _deferred_post_tool_blocks( + content, first_tool_index=first_tool_index + ) + + pre_msg: dict[str, Any] + if not pre: + pre_msg = { + "role": "assistant", + "content": "", + } + if reasoning_replay == ReasoningReplayMode.REASONING_CONTENT: + replay = reasoning_content + if replay is not None: + pre_msg["reasoning_content"] = replay + else: + pre_msg = AnthropicToOpenAIConverter._convert_assistant_message( + pre, + reasoning_content=reasoning_content, + reasoning_replay=reasoning_replay, + )[0] + pre_msg["tool_calls"] = tool_calls + if tool_calls and pre_msg.get("content") == " ": + pre_msg["content"] = "" + return _ToolTurnSegment( + assistant_message=pre_msg, + required_tool_ids=_tool_call_ids(tool_calls), + deferred_blocks=deferred_blocks, + top_level_reasoning=reasoning_content, + reasoning_replay=reasoning_replay, + ) + + @staticmethod + def _convert_assistant_message( + content: list[Any], + *, + reasoning_content: str | None = None, + reasoning_replay: ReasoningReplayMode = ReasoningReplayMode.THINK_TAGS, + ) -> list[dict[str, Any]]: + content_parts: list[str] = [] + thinking_parts: list[str] = [] + thinking_seen = False + tool_calls: list[dict[str, Any]] = [] + for block in content: + block_type = get_block_type(block) + if block_type == "text": + content_parts.append(get_block_attr(block, "text", "")) + elif block_type == "thinking": + if reasoning_replay == ReasoningReplayMode.DISABLED: + continue + thinking = get_block_attr(block, "thinking", "") + if reasoning_replay == ReasoningReplayMode.THINK_TAGS: + content_parts.append(_think_tag_content(thinking)) + elif reasoning_content is None: + thinking_seen = True + thinking_parts.append(thinking) + elif block_type == "redacted_thinking": + # Opaque provider continuation data; do not materialize as model-visible text + # or reasoning_content for OpenAI chat upstreams. + continue + elif block_type == "tool_use": + tool_calls.append(_tool_call_from_tool_use(block)) + else: + _assert_no_forbidden_assistant_block(block) + + content_str = "\n\n".join(content_parts) + if not content_str and not tool_calls: + content_str = " " + + msg: dict[str, Any] = { + "role": "assistant", + "content": content_str, + } + if tool_calls: + msg["tool_calls"] = tool_calls + if reasoning_replay == ReasoningReplayMode.REASONING_CONTENT: + if reasoning_content is not None: + msg["reasoning_content"] = reasoning_content + elif thinking_seen: + msg["reasoning_content"] = "\n".join(thinking_parts) + + return [msg] + + @staticmethod + def _deferred_post_tool_to_messages( + pending: _ToolTurnSegment, + ) -> list[dict[str, Any]]: + if not pending.deferred_blocks: + return [] + return AnthropicToOpenAIConverter._convert_assistant_message( + pending.deferred_blocks, + reasoning_content=pending.top_level_reasoning, + reasoning_replay=pending.reasoning_replay, + ) + + @staticmethod + def _convert_user_message(content: list[Any]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + text_parts: list[str] = [] + + def flush_text() -> None: + if text_parts: + result.append({"role": "user", "content": "\n".join(text_parts)}) + text_parts.clear() + + for block in content: + block_type = get_block_type(block) + + if block_type == "text": + text_parts.append(get_block_attr(block, "text", "")) + elif block_type == "image": + raise OpenAIConversionError( + "User message image blocks are not supported for OpenAI chat " + "conversion; remove the image blocks or extend the converter." + ) + elif block_type == "tool_result": + flush_text() + tool_content = get_block_attr(block, "content", "") + serialized = serialize_tool_result_content(tool_content) + result.append( + { + "role": "tool", + "tool_call_id": get_block_attr(block, "tool_use_id"), + "content": serialized if serialized else "", + } + ) + + flush_text() + return result + + @staticmethod + def convert_tools(tools: list[Any]) -> list[dict[str, Any]]: + return [ + { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description or "", + "parameters": _tool_input_schema(tool), + }, + } + for tool in tools + ] + + @staticmethod + def convert_tool_choice(tool_choice: Any) -> Any: + if not isinstance(tool_choice, dict): + return tool_choice + + choice_type = tool_choice.get("type") + if choice_type == "tool": + name = tool_choice.get("name") + if name: + return {"type": "function", "function": {"name": name}} + if choice_type == "any": + return "required" + if choice_type in {"auto", "none", "required"}: + return choice_type + if choice_type == "function" and isinstance(tool_choice.get("function"), dict): + return tool_choice + + return tool_choice + + @staticmethod + def convert_system_prompt(system: Any) -> dict[str, str] | None: + if isinstance(system, str): + return {"role": "system", "content": system} + if isinstance(system, list): + text_parts = [ + get_block_attr(block, "text", "") + for block in system + if get_block_type(block) == "text" + ] + if text_parts: + return {"role": "system", "content": "\n\n".join(text_parts).strip()} + return None + + +def build_base_request_body( + request_data: MessagesRequest, + *, + default_max_tokens: int | None = None, + reasoning_replay: ReasoningReplayMode = ReasoningReplayMode.THINK_TAGS, +) -> dict[str, Any]: + """Build the common parts of an OpenAI-format request body.""" + _openai_reject_native_only_top_level_fields(request_data) + messages = AnthropicToOpenAIConverter.convert_messages( + request_data.messages, + reasoning_replay=reasoning_replay, + ) + + system = request_data.system + if system: + system_msg = AnthropicToOpenAIConverter.convert_system_prompt(system) + if system_msg: + messages.insert(0, system_msg) + + body: dict[str, Any] = {"model": request_data.model, "messages": messages} + + max_tokens = request_data.max_tokens + set_if_not_none(body, "max_tokens", max_tokens or default_max_tokens) + set_if_not_none(body, "temperature", request_data.temperature) + set_if_not_none(body, "top_p", request_data.top_p) + + stop_sequences = request_data.stop_sequences + if stop_sequences: + body["stop"] = stop_sequences + + tools = request_data.tools + if tools: + body["tools"] = AnthropicToOpenAIConverter.convert_tools(tools) + tool_choice = request_data.tool_choice + if tool_choice: + body["tool_choice"] = AnthropicToOpenAIConverter.convert_tool_choice( + tool_choice + ) + + return body diff --git a/src/free_claude_code/core/anthropic/errors.py b/src/free_claude_code/core/anthropic/errors.py new file mode 100644 index 0000000..200b111 --- /dev/null +++ b/src/free_claude_code/core/anthropic/errors.py @@ -0,0 +1,87 @@ +"""Anthropic error types and envelopes.""" + +from typing import Any + +from free_claude_code.core.diagnostics import redact_sensitive_error_text +from free_claude_code.core.failures import ExecutionFailure, FailureKind + +_ANTHROPIC_ERROR_STATUS_CODES = { + "invalid_request_error": 400, + "authentication_error": 401, + "billing_error": 402, + "permission_error": 403, + "not_found_error": 404, + "request_too_large": 413, + "rate_limit_error": 429, + "api_error": 500, + "timeout_error": 504, + "overloaded_error": 529, +} + +_FAILURE_ERROR_TYPES = { + FailureKind.INVALID_REQUEST: "invalid_request_error", + FailureKind.AUTHENTICATION: "authentication_error", + FailureKind.PERMISSION: "permission_error", + FailureKind.RATE_LIMIT: "rate_limit_error", + FailureKind.OVERLOADED: "overloaded_error", + FailureKind.TIMEOUT: "api_error", + FailureKind.UPSTREAM: "api_error", + FailureKind.UNAVAILABLE: "api_error", +} + + +def anthropic_error_type_for_failure( + failure: FailureKind | ExecutionFailure, +) -> str: + """Map neutral failure semantics to an Anthropic wire type.""" + if isinstance(failure, ExecutionFailure): + if failure.kind == FailureKind.PERMISSION and failure.status_code == 402: + return "billing_error" + if failure.kind == FailureKind.INVALID_REQUEST: + if failure.status_code == 404: + return "not_found_error" + if failure.status_code == 413: + return "request_too_large" + if failure.kind == FailureKind.TIMEOUT and failure.status_code == 504: + return "timeout_error" + kind = failure.kind + else: + kind = failure + return _FAILURE_ERROR_TYPES[kind] + + +def anthropic_error_payload( + *, + error_type: str, + message: str, + request_id: str | None = None, +) -> dict[str, Any]: + """Return one Anthropic-compatible JSON error envelope.""" + payload: dict[str, Any] = { + "type": "error", + "error": { + "type": error_type, + "message": redact_sensitive_error_text(message), + }, + } + if request_id: + payload["request_id"] = request_id + return payload + + +def anthropic_failure_payload( + failure: ExecutionFailure, + *, + request_id: str | None = None, +) -> dict[str, Any]: + """Serialize a canonical execution failure as an Anthropic JSON error.""" + return anthropic_error_payload( + error_type=anthropic_error_type_for_failure(failure), + message=failure.message, + request_id=request_id, + ) + + +def anthropic_status_for_error_type(error_type: str) -> int: + """Return the standard HTTP status for an Anthropic error type.""" + return _ANTHROPIC_ERROR_STATUS_CODES.get(error_type, 500) diff --git a/src/free_claude_code/core/anthropic/models.py b/src/free_claude_code/core/anthropic/models.py new file mode 100644 index 0000000..105c12a --- /dev/null +++ b/src/free_claude_code/core/anthropic/models.py @@ -0,0 +1,250 @@ +"""Pydantic models for the Anthropic Messages protocol.""" + +from enum import StrEnum +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class Role(StrEnum): + user = "user" + assistant = "assistant" + system = "system" + + +class _AnthropicBlockBase(BaseModel): + """Pass through protocol extensions such as ``cache_control``.""" + + model_config = ConfigDict(extra="allow") + + +class ContentBlockText(_AnthropicBlockBase): + type: Literal["text"] + text: str + + +class ContentBlockImage(_AnthropicBlockBase): + type: Literal["image"] + source: dict[str, Any] + + +class ContentBlockDocument(_AnthropicBlockBase): + """Anthropic document block (e.g. PDF files via the Files API).""" + + type: Literal["document"] + source: dict[str, Any] + + +class ContentBlockToolUse(_AnthropicBlockBase): + type: Literal["tool_use"] + id: str + name: str + input: dict[str, Any] + + +class ContentBlockToolResult(_AnthropicBlockBase): + type: Literal["tool_result"] + tool_use_id: str + content: str | list[Any] | dict[str, Any] + + +class ContentBlockThinking(_AnthropicBlockBase): + type: Literal["thinking"] + thinking: str + signature: str | None = None + + +class ContentBlockRedactedThinking(_AnthropicBlockBase): + type: Literal["redacted_thinking"] + data: str + + +class ContentBlockServerToolUse(_AnthropicBlockBase): + """Anthropic server-side tool invocation (e.g. ``web_search``, ``web_fetch``).""" + + type: Literal["server_tool_use"] + id: str + name: str + input: dict[str, Any] + + +class ContentBlockWebSearchToolResult(_AnthropicBlockBase): + type: Literal["web_search_tool_result"] + tool_use_id: str + content: Any + + +class ContentBlockWebFetchToolResult(_AnthropicBlockBase): + type: Literal["web_fetch_tool_result"] + tool_use_id: str + content: Any + + +class SystemContent(_AnthropicBlockBase): + type: Literal["text"] + text: str + + +def _text_system_block(text: str) -> dict[str, str]: + return {"type": "text", "text": text} + + +def _merge_system_values(existing: Any, additions: list[Any]) -> Any: + values = [existing] if existing is not None else [] + values.extend(additions) + + if all(isinstance(value, str) for value in values): + return "\n\n".join(value for value in values if value) + + blocks: list[Any] = [] + for value in values: + if isinstance(value, str): + blocks.append(_text_system_block(value)) + elif isinstance(value, list): + blocks.extend(value) + else: + blocks.append(value) + return blocks + + +def _normalize_system_role_messages(data: Any) -> Any: + if not isinstance(data, dict): + return data + + messages = data.get("messages") + if not isinstance(messages, list): + return data + + system_contents: list[Any] = [] + normalized_messages: list[Any] = [] + for message in messages: + role = message.get("role") if isinstance(message, dict) else None + if role == Role.system: + system_contents.append(message.get("content", "")) + continue + normalized_messages.append(message) + + if not system_contents: + return data + + normalized = dict(data) + normalized["messages"] = normalized_messages + normalized["system"] = _merge_system_values( + normalized.get("system"), system_contents + ) + return normalized + + +class Message(BaseModel): + role: Literal["user", "assistant"] + content: ( + str + | list[ + ContentBlockText + | ContentBlockImage + | ContentBlockDocument + | ContentBlockToolUse + | ContentBlockToolResult + | ContentBlockThinking + | ContentBlockRedactedThinking + | ContentBlockServerToolUse + | ContentBlockWebSearchToolResult + | ContentBlockWebFetchToolResult + ] + ) + reasoning_content: str | None = None + + +class Tool(_AnthropicBlockBase): + name: str + type: str | None = None + description: str | None = None + input_schema: dict[str, Any] | None = None + + +class ThinkingConfig(BaseModel): + enabled: bool | None = True + type: str | None = None + budget_tokens: int | None = None + + +class MessagesRequest(BaseModel): + model_config = ConfigDict(extra="allow") + + @model_validator(mode="before") + @classmethod + def normalize_system_role_messages(cls, data: Any) -> Any: + return _normalize_system_role_messages(data) + + model: str + original_model: str | None = Field(default=None, exclude=True) + resolved_provider_model: str | None = Field(default=None, exclude=True) + max_tokens: int | None = None + messages: list[Message] + system: str | list[SystemContent] | None = None + stop_sequences: list[str] | None = None + stream: bool | None = True + temperature: float | None = None + top_p: float | None = None + top_k: int | None = None + metadata: dict[str, Any] | None = None + tools: list[Tool] | None = None + tool_choice: dict[str, Any] | None = None + thinking: ThinkingConfig | None = None + context_management: dict[str, Any] | None = None + output_config: dict[str, Any] | None = None + mcp_servers: list[dict[str, Any]] | None = None + extra_body: dict[str, Any] | None = None + betas: list[str] | None = Field(default=None, exclude=True) + + +class TokenCountRequest(BaseModel): + model_config = ConfigDict(extra="allow") + + @model_validator(mode="before") + @classmethod + def normalize_system_role_messages(cls, data: Any) -> Any: + return _normalize_system_role_messages(data) + + model: str + original_model: str | None = Field(default=None, exclude=True) + resolved_provider_model: str | None = Field(default=None, exclude=True) + messages: list[Message] + system: str | list[SystemContent] | None = None + tools: list[Tool] | None = None + thinking: ThinkingConfig | None = None + tool_choice: dict[str, Any] | None = None + context_management: dict[str, Any] | None = None + output_config: dict[str, Any] | None = None + mcp_servers: list[dict[str, Any]] | None = None + betas: list[str] | None = Field(default=None, exclude=True) + + +class TokenCountResponse(BaseModel): + input_tokens: int + + +class Usage(BaseModel): + input_tokens: int + output_tokens: int + cache_creation_input_tokens: int = 0 + cache_read_input_tokens: int = 0 + + +class MessagesResponse(BaseModel): + id: str + model: str + role: Literal["assistant"] = "assistant" + content: list[ + ContentBlockText + | ContentBlockToolUse + | ContentBlockThinking + | ContentBlockRedactedThinking + | dict[str, Any] + ] + type: Literal["message"] = "message" + stop_reason: ( + Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"] | None + ) = None + stop_sequence: str | None = None + usage: Usage diff --git a/src/free_claude_code/core/anthropic/request_serialization.py b/src/free_claude_code/core/anthropic/request_serialization.py new file mode 100644 index 0000000..7b3aba6 --- /dev/null +++ b/src/free_claude_code/core/anthropic/request_serialization.py @@ -0,0 +1,57 @@ +"""Shared Anthropic request serialization helpers.""" + +import json +from typing import Any + +from .models import MessagesRequest + +_MESSAGES_REQUEST_FIELDS = ( + "model", + "messages", + "system", + "max_tokens", + "stop_sequences", + "stream", + "temperature", + "top_p", + "top_k", + "metadata", + "tools", + "tool_choice", + "thinking", + "context_management", + "output_config", + "mcp_servers", + "extra_body", +) + + +def dump_messages_request(request: MessagesRequest) -> dict[str, Any]: + """Return JSON-ready public Messages fields without FCC routing state.""" + raw = request.model_dump(exclude_none=True) + return { + field: raw[field] + for field in _MESSAGES_REQUEST_FIELDS + if field in raw and raw[field] is not None + } + + +def serialize_tool_result_content(content: Any) -> str: + """Serialize Anthropic ``tool_result.content`` into provider-safe text.""" + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, dict): + return json.dumps(content, ensure_ascii=False) + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + parts.append(str(item.get("text", ""))) + elif isinstance(item, dict): + parts.append(json.dumps(item, ensure_ascii=False)) + else: + parts.append(str(item)) + return "\n".join(parts) + return str(content) diff --git a/src/free_claude_code/core/anthropic/request_snapshot.py b/src/free_claude_code/core/anthropic/request_snapshot.py new file mode 100644 index 0000000..75c1d5b --- /dev/null +++ b/src/free_claude_code/core/anthropic/request_snapshot.py @@ -0,0 +1,36 @@ +"""Trace-safe snapshots of Anthropic protocol requests.""" + +from typing import Any + +from free_claude_code.core.trace import sanitize_trace_value + +from .models import MessagesRequest, TokenCountRequest + + +def anthropic_request_snapshot( + request: MessagesRequest | TokenCountRequest, +) -> dict[str, Any]: + """Return the traceable public fields of an Anthropic request.""" + data = request.model_dump(mode="python") + snapshot = { + key: data[key] + for key in ( + "model", + "messages", + "system", + "tools", + "tool_choice", + "max_tokens", + "thinking", + "temperature", + "top_p", + "top_k", + "stop_sequences", + "metadata", + "stream", + "thinking_enabled", + ) + if key in data and data[key] is not None + } + sanitized = sanitize_trace_value(snapshot) + return sanitized if isinstance(sanitized, dict) else {} diff --git a/src/free_claude_code/core/anthropic/server_tool_sse.py b/src/free_claude_code/core/anthropic/server_tool_sse.py new file mode 100644 index 0000000..2de1a82 --- /dev/null +++ b/src/free_claude_code/core/anthropic/server_tool_sse.py @@ -0,0 +1,12 @@ +"""SSE content_block ``type`` values for Anthropic web server tools (local handlers). + +Shared by :mod:`api.web_tools` and stream contract tests to avoid drift. +""" + +from typing import Final + +SERVER_TOOL_USE: Final = "server_tool_use" +WEB_SEARCH_TOOL_RESULT: Final = "web_search_tool_result" +WEB_FETCH_TOOL_RESULT: Final = "web_fetch_tool_result" +WEB_SEARCH_TOOL_RESULT_ERROR: Final = "web_search_tool_result_error" +WEB_FETCH_TOOL_ERROR: Final = "web_fetch_tool_error" diff --git a/src/free_claude_code/core/anthropic/sse_aggregation.py b/src/free_claude_code/core/anthropic/sse_aggregation.py new file mode 100644 index 0000000..0827380 --- /dev/null +++ b/src/free_claude_code/core/anthropic/sse_aggregation.py @@ -0,0 +1,128 @@ +"""Fold an Anthropic Messages SSE stream into a single JSON Message body. + +Used to honor client requests with ``stream: false``. The internal pipeline +is always SSE (provider execution always yields a stream), so +callers that need a non-streaming response consume the stream here and get +back the same shape the real Anthropic API returns for a non-streaming +``messages.create()`` call. +""" + +import json +import uuid +from collections.abc import AsyncIterator +from typing import Any + +from .stream_contracts import parse_sse_text + +__all__ = ["aggregate_anthropic_sse_to_message"] + + +async def aggregate_anthropic_sse_to_message( + stream: AsyncIterator[str], +) -> tuple[dict[str, Any], dict[str, Any] | None]: + """Assemble a complete Messages JSON body from an Anthropic SSE stream. + + Returns ``(message_body, error)`` where ``error`` is the payload of a + top-level ``event: error`` if one arrived, else ``None``. + """ + buffer = "" + message: dict[str, Any] = {} + blocks: dict[int, dict[str, Any]] = {} + parts: dict[int, list[str]] = {} + error: dict[str, Any] | None = None + + def handle_payload(payload: dict[str, Any]) -> None: + nonlocal message, error + ptype = payload.get("type") + if ptype == "message_start": + started = payload.get("message") + if isinstance(started, dict): + message = dict(started) + elif ptype == "content_block_start": + idx = payload.get("index") + block = payload.get("content_block") + if isinstance(idx, int) and isinstance(block, dict): + blocks[idx] = dict(block) + parts.setdefault(idx, []) + elif ptype == "content_block_delta": + idx = payload.get("index") + delta = payload.get("delta") + if not isinstance(idx, int) or not isinstance(delta, dict): + return + if idx not in blocks: + blocks[idx] = {"type": "text", "text": ""} + parts[idx] = [] + dtype = delta.get("type") + if dtype == "text_delta": + parts[idx].append(str(delta.get("text", ""))) + elif dtype == "thinking_delta": + parts[idx].append(str(delta.get("thinking", ""))) + elif dtype == "input_json_delta": + parts[idx].append(str(delta.get("partial_json", ""))) + elif dtype == "signature_delta": + blocks[idx]["signature"] = str(delta.get("signature", "")) + elif ptype == "message_delta": + delta = payload.get("delta") + if isinstance(delta, dict): + if delta.get("stop_reason"): + message["stop_reason"] = delta["stop_reason"] + if "stop_sequence" in delta: + message["stop_sequence"] = delta["stop_sequence"] + usage = payload.get("usage") + if isinstance(usage, dict): + merged = ( + dict(message["usage"]) + if isinstance(message.get("usage"), dict) + else {} + ) + merged.update( + {k: v for k, v in usage.items() if isinstance(v, int | dict)} + ) + message["usage"] = merged + elif ptype == "error": + err = payload.get("error") + error = ( + err + if isinstance(err, dict) + else {"type": "api_error", "message": "provider error"} + ) + + async for chunk in stream: + buffer += chunk + while "\n\n" in buffer: + raw_event, buffer = buffer.split("\n\n", 1) + for event in parse_sse_text(raw_event + "\n\n"): + handle_payload(event.data) + + content: list[dict[str, Any]] = [] + for idx in sorted(blocks): + block = blocks[idx] + accumulated = "".join(parts.get(idx, [])) + btype = block.get("type") + if btype == "text": + block["text"] = str(block.get("text", "")) + accumulated + elif btype == "thinking": + block["thinking"] = str(block.get("thinking", "")) + accumulated + block.setdefault("signature", "") + elif btype == "tool_use": + if accumulated.strip(): + try: + block["input"] = json.loads(accumulated) + except json.JSONDecodeError: + block["input"] = block.get("input") or {} + elif not isinstance(block.get("input"), dict): + block["input"] = {} + content.append(block) + + message["content"] = content + message.setdefault("id", f"msg_{uuid.uuid4()}") + message.setdefault("type", "message") + message.setdefault("role", "assistant") + message.setdefault("model", "unknown") + message.setdefault("stop_reason", "end_turn") + message.setdefault("stop_sequence", None) + usage = dict(message["usage"]) if isinstance(message.get("usage"), dict) else {} + usage.setdefault("input_tokens", 0) + usage.setdefault("output_tokens", 0) + message["usage"] = usage + return message, error diff --git a/src/free_claude_code/core/anthropic/stream_contracts.py b/src/free_claude_code/core/anthropic/stream_contracts.py new file mode 100644 index 0000000..d635f4e --- /dev/null +++ b/src/free_claude_code/core/anthropic/stream_contracts.py @@ -0,0 +1,206 @@ +"""Neutral SSE parsing and Anthropic stream shape assertions. + +Used by default CI contract tests and by opt-in live smoke scenarios. +""" + +import json +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Any + +from .server_tool_sse import ( + SERVER_TOOL_USE, + WEB_FETCH_TOOL_RESULT, + WEB_SEARCH_TOOL_RESULT, +) + +# Content blocks that only use content_block_start/stop (no deltas), including +# Anthropic server tools and eager text emitted in a single start event. +_NO_DELTA_BLOCK_KINDS = frozenset( + { + SERVER_TOOL_USE, + WEB_SEARCH_TOOL_RESULT, + WEB_FETCH_TOOL_RESULT, + "text_eager", + "redacted_thinking", + } +) + +_ALLOWED_BLOCK_START_TYPES = frozenset( + { + "text", + "thinking", + "tool_use", + "redacted_thinking", + SERVER_TOOL_USE, + WEB_SEARCH_TOOL_RESULT, + WEB_FETCH_TOOL_RESULT, + } +) + + +@dataclass(frozen=True, slots=True) +class SSEEvent: + event: str + data: dict[str, Any] + raw: str + + +def parse_sse_lines(lines: Iterable[str]) -> list[SSEEvent]: + events: list[SSEEvent] = [] + current_event = "" + data_parts: list[str] = [] + raw_parts: list[str] = [] + + for line in lines: + stripped = line.rstrip("\r\n") + if stripped == "": + _append_event(events, current_event, data_parts, raw_parts) + current_event = "" + data_parts = [] + raw_parts = [] + continue + raw_parts.append(stripped) + if stripped.startswith("event:"): + current_event = stripped.split(":", 1)[1].strip() + elif stripped.startswith("data:"): + data_parts.append(stripped.split(":", 1)[1].strip()) + + _append_event(events, current_event, data_parts, raw_parts) + return events + + +def parse_sse_text(text: str) -> list[SSEEvent]: + return parse_sse_lines(text.splitlines()) + + +def _append_event( + events: list[SSEEvent], + current_event: str, + data_parts: list[str], + raw_parts: list[str], +) -> None: + if not current_event and not data_parts: + return + data_text = "\n".join(data_parts) + data: dict[str, Any] + try: + parsed = json.loads(data_text) if data_text else {} + data = parsed if isinstance(parsed, dict) else {"value": parsed} + except json.JSONDecodeError: + data = {"raw": data_text} + events.append(SSEEvent(current_event, data, "\n".join(raw_parts))) + + +def assert_anthropic_stream_contract( + events: list[SSEEvent], *, allow_error: bool = False +) -> None: + """Check minimal Anthropic-style SSE invariants and block nesting. + + Does *not* assert strict event ordering (e.g. :class:`message_delta` vs + content blocks). Successful streams end in ``message_stop``; when explicitly + allowed, failed streams may instead end in a protocol-native ``error``. + """ + assert events, "stream produced no SSE events" + event_names = [event.event for event in events] + assert "message_start" in event_names, event_names + allowed_terminal_events = ( + {"message_stop", "error"} if allow_error else {"message_stop"} + ) + assert event_names[-1] in allowed_terminal_events, event_names + + open_blocks: dict[int, str] = {} + seen_blocks: set[int] = set() + for event in events: + if event.event == "error" and not allow_error: + raise AssertionError(f"unexpected SSE error event: {event.data}") + + if event.event == "content_block_start": + index = event_index(event) + block = event.data.get("content_block", {}) + assert isinstance(block, dict), event.data + block_type = str(block.get("type", "")) + assert block_type in _ALLOWED_BLOCK_START_TYPES, event.data + assert index not in open_blocks, f"block {index} started twice" + assert index not in seen_blocks, f"block {index} reused after stop" + if block_type == "text" and str(block.get("text", "")).strip(): + storage = "text_eager" + else: + storage = block_type + open_blocks[index] = storage + seen_blocks.add(index) + continue + + if event.event == "content_block_delta": + index = event_index(event) + assert index in open_blocks, f"delta for unopened block {index}" + kind = open_blocks[index] + assert kind not in _NO_DELTA_BLOCK_KINDS, ( + f"unexpected delta for start/stop-only block {kind} at index {index}" + ) + delta = event.data.get("delta", {}) + assert isinstance(delta, dict), event.data + delta_type = str(delta.get("type", "")) + if kind == "thinking": + assert delta_type in ( + "thinking_delta", + "signature_delta", + ), f"block {index} is {kind}, got {delta_type}" + continue + expected = { + "text": "text_delta", + "tool_use": "input_json_delta", + }[kind] + assert delta_type == expected, f"block {index} is {kind}, got {delta_type}" + continue + + if event.event == "content_block_stop": + index = event_index(event) + assert index in open_blocks, f"stop for unopened block {index}" + open_blocks.pop(index) + + assert not open_blocks, f"unclosed blocks: {open_blocks}" + assert seen_blocks, "stream did not emit any content blocks" + + +def event_names(events: list[SSEEvent]) -> list[str]: + return [event.event for event in events] + + +def text_content(events: list[SSEEvent]) -> str: + parts: list[str] = [] + for event in events: + if event.event == "content_block_start": + block = event.data.get("content_block", {}) + if isinstance(block, dict) and block.get("type") == "text": + eager = str(block.get("text", "")) + if eager: + parts.append(eager) + delta = event.data.get("delta", {}) + if isinstance(delta, dict) and delta.get("type") == "text_delta": + parts.append(str(delta.get("text", ""))) + return "".join(parts) + + +def thinking_content(events: list[SSEEvent]) -> str: + parts: list[str] = [] + for event in events: + delta = event.data.get("delta", {}) + if isinstance(delta, dict) and delta.get("type") == "thinking_delta": + parts.append(str(delta.get("thinking", ""))) + return "".join(parts) + + +def has_tool_use(events: list[SSEEvent]) -> bool: + for event in events: + block = event.data.get("content_block", {}) + if isinstance(block, dict) and block.get("type") == "tool_use": + return True + return False + + +def event_index(event: SSEEvent) -> int: + """Return the content block ``index`` field from an SSE payload (strict).""" + value = event.data.get("index") + assert isinstance(value, int), event.data + return value diff --git a/src/free_claude_code/core/anthropic/streaming/__init__.py b/src/free_claude_code/core/anthropic/streaming/__init__.py new file mode 100644 index 0000000..280ddab --- /dev/null +++ b/src/free_claude_code/core/anthropic/streaming/__init__.py @@ -0,0 +1,39 @@ +"""Shared Anthropic streaming engine.""" + +from .emitter import ( + ANTHROPIC_SSE_RESPONSE_HEADERS, + AnthropicSseEmitter, + anthropic_terminal_error_frame, + anthropic_terminal_failure_frame, + format_sse_event, + map_stop_reason, +) +from .ledger import AnthropicStreamLedger, StreamBlockLedger, ToolBlockState +from .recovery import ( + ToolSchema, + accept_tool_json_repair, + continuation_suffix, + make_text_recovery_body, + make_tool_repair_body, + parse_complete_tool_input, + tool_schemas_by_name, +) + +__all__ = [ + "ANTHROPIC_SSE_RESPONSE_HEADERS", + "AnthropicSseEmitter", + "AnthropicStreamLedger", + "StreamBlockLedger", + "ToolBlockState", + "ToolSchema", + "accept_tool_json_repair", + "anthropic_terminal_error_frame", + "anthropic_terminal_failure_frame", + "continuation_suffix", + "format_sse_event", + "make_text_recovery_body", + "make_tool_repair_body", + "map_stop_reason", + "parse_complete_tool_input", + "tool_schemas_by_name", +] diff --git a/src/free_claude_code/core/anthropic/streaming/emitter.py b/src/free_claude_code/core/anthropic/streaming/emitter.py new file mode 100644 index 0000000..3fab009 --- /dev/null +++ b/src/free_claude_code/core/anthropic/streaming/emitter.py @@ -0,0 +1,62 @@ +"""Anthropic SSE serialization helpers.""" + +import json +from typing import Any + +from loguru import logger + +from free_claude_code.core.failures import ExecutionFailure + +from ..errors import anthropic_error_payload, anthropic_failure_payload + +ANTHROPIC_SSE_RESPONSE_HEADERS: dict[str, str] = { + "X-Accel-Buffering": "no", + "Cache-Control": "no-cache", + "Connection": "keep-alive", +} + +STOP_REASON_MAP = { + "stop": "end_turn", + "length": "max_tokens", + "tool_calls": "tool_use", + "content_filter": "end_turn", +} + + +def map_stop_reason(openai_reason: str | None) -> str: + """Map OpenAI ``finish_reason`` values to Anthropic ``stop_reason`` values.""" + return ( + STOP_REASON_MAP.get(openai_reason, "end_turn") if openai_reason else "end_turn" + ) + + +def format_sse_event(event_type: str, data: dict[str, Any]) -> str: + """Format one Anthropic-style SSE event.""" + return f"event: {event_type}\ndata: {json.dumps(data)}\n\n" + + +def anthropic_terminal_error_frame( + message: str, *, error_type: str = "api_error" +) -> str: + """Serialize a terminal Anthropic SSE error event for egress failures.""" + return format_sse_event( + "error", anthropic_error_payload(error_type=error_type, message=message) + ) + + +def anthropic_terminal_failure_frame(failure: ExecutionFailure) -> str: + """Serialize a canonical execution failure as a terminal SSE event.""" + return format_sse_event("error", anthropic_failure_payload(failure)) + + +class AnthropicSseEmitter: + """Serialize Anthropic SSE events and optionally log raw event bodies.""" + + def __init__(self, *, log_raw_events: bool = False) -> None: + self._log_raw_events = log_raw_events + + def event(self, event_type: str, data: dict[str, Any]) -> str: + event = format_sse_event(event_type, data) + if self._log_raw_events: + logger.debug("SSE_EVENT: {} - {}", event_type, event.strip()) + return event diff --git a/src/free_claude_code/core/anthropic/streaming/ledger.py b/src/free_claude_code/core/anthropic/streaming/ledger.py new file mode 100644 index 0000000..5625945 --- /dev/null +++ b/src/free_claude_code/core/anthropic/streaming/ledger.py @@ -0,0 +1,554 @@ +"""Anthropic stream state ledger.""" + +import hashlib +import json +import uuid +from collections.abc import Iterator, Mapping +from contextlib import suppress +from dataclasses import dataclass, field +from typing import Any + +from loguru import logger + +from .emitter import AnthropicSseEmitter +from .recovery import ( + ToolSchema, + parse_complete_tool_input, +) + +try: + import tiktoken + + ENCODER = tiktoken.get_encoding("cl100k_base") +except Exception: + ENCODER = None + + +def _safe_usage_int(value: object) -> int: + return value if isinstance(value, int) else 0 + + +@dataclass +class ToolBlockState: + """State for one streamed tool call.""" + + block_index: int + tool_id: str + name: str + extra_content: dict[str, Any] | None = None + started: bool = False + task_arg_buffer: str = "" + task_args_emitted: bool = False + pre_start_args: str = "" + + +@dataclass +class StreamBlockState: + """Tracked downstream Anthropic content block.""" + + index: int + block_type: str + open: bool = True + tool_id: str = "" + name: str = "" + parts: list[str] = field(default_factory=list) + extra_content: dict[str, Any] | None = None + + @property + def content(self) -> str: + return "".join(self.parts) + + +@dataclass +class StreamBlockLedger: + """Allocate and track Anthropic content block indexes.""" + + next_index: int = 0 + thinking_index: int = -1 + text_index: int = -1 + thinking_started: bool = False + text_started: bool = False + tool_states: dict[int, ToolBlockState] = field(default_factory=dict) + + def allocate_index(self) -> int: + idx = self.next_index + self.next_index += 1 + return idx + + def reserve_index(self, index: int) -> None: + self.next_index = max(self.next_index, index + 1) + + def ensure_tool_state(self, index: int) -> ToolBlockState: + if index not in self.tool_states: + self.tool_states[index] = ToolBlockState( + block_index=-1, tool_id="", name="" + ) + return self.tool_states[index] + + def set_stream_tool_id(self, index: int, tool_id: str | None) -> None: + if not tool_id: + return + self.ensure_tool_state(index).tool_id = str(tool_id) + + def set_tool_extra_content( + self, index: int, extra_content: dict[str, Any] | None + ) -> None: + if extra_content: + self.ensure_tool_state(index).extra_content = extra_content + + def register_tool_name(self, index: int, name: str) -> None: + if index not in self.tool_states: + self.tool_states[index] = ToolBlockState( + block_index=-1, tool_id="", name=name + ) + return + state = self.tool_states[index] + prev = state.name + if not prev or name.startswith(prev): + state.name = name + elif not prev.startswith(name): + state.name = prev + name + + def buffer_task_args(self, index: int, args: str) -> dict[str, Any] | None: + state = self.tool_states.get(index) + if state is None or state.task_args_emitted: + return None + + state.task_arg_buffer += args + try: + args_json = json.loads(state.task_arg_buffer) + except Exception: + return None + if not isinstance(args_json, dict): + return None + + _normalize_task_run_in_background(args_json) + state.task_args_emitted = True + state.task_arg_buffer = "" + return args_json + + def flush_task_arg_buffers(self) -> list[tuple[int, str]]: + results: list[tuple[int, str]] = [] + for tool_index, state in list(self.tool_states.items()): + if not state.task_arg_buffer or state.task_args_emitted: + continue + + out = "{}" + try: + args_json = json.loads(state.task_arg_buffer) + if isinstance(args_json, dict): + _normalize_task_run_in_background(args_json) + out = json.dumps(args_json) + except (json.JSONDecodeError, TypeError, ValueError) as exc: + digest = hashlib.sha256( + state.task_arg_buffer.encode("utf-8", errors="replace") + ).hexdigest()[:16] + logger.warning( + "Task args invalid JSON (id={} len={} buffer_sha256_prefix={}): {}", + state.tool_id or "unknown", + len(state.task_arg_buffer), + digest, + exc, + ) + + state.task_args_emitted = True + state.task_arg_buffer = "" + results.append((tool_index, out)) + return results + + +class AnthropicStreamLedger: + """Own mutable Anthropic stream state and produce serialized SSE events.""" + + def __init__( + self, + message_id: str | None, + model: str, + input_tokens: int = 0, + *, + log_raw_events: bool = False, + ) -> None: + self.message_id = message_id or f"msg_{uuid.uuid4()}" + self.model = model + self.input_tokens = input_tokens + self.blocks = StreamBlockLedger() + self._emitter = AnthropicSseEmitter(log_raw_events=log_raw_events) + self._text_parts: list[str] = [] + self._thinking_parts: list[str] = [] + self._open_stack: list[int] = [] + self._content_blocks: dict[int, StreamBlockState] = {} + self.message_started = False + self.message_stopped = False + self.stop_reason: str | None = None + + def message_start(self) -> str: + self.message_started = True + safe_input = _safe_usage_int(self.input_tokens) + return self._emitter.event( + "message_start", + { + "type": "message_start", + "message": { + "id": self.message_id, + "type": "message", + "role": "assistant", + "content": [], + "model": self.model, + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": safe_input, "output_tokens": 1}, + }, + }, + ) + + def message_delta( + self, + stop_reason: str, + output_tokens: int | None, + *, + input_tokens: int | None = None, + usage_fields: Mapping[str, int] | None = None, + ) -> str: + self.stop_reason = stop_reason + safe_in = _safe_usage_int( + self.input_tokens if input_tokens is None else input_tokens + ) + safe_out = output_tokens if isinstance(output_tokens, int) else 0 + usage = {"input_tokens": safe_in, "output_tokens": safe_out} + if usage_fields: + usage.update( + { + key: value + for key, value in usage_fields.items() + if isinstance(value, int) + } + ) + return self._emitter.event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": stop_reason, "stop_sequence": None}, + "usage": usage, + }, + ) + + def message_stop(self) -> str: + self.message_stopped = True + return self._emitter.event("message_stop", {"type": "message_stop"}) + + def content_block_start(self, index: int, block_type: str, **kwargs: Any) -> str: + content_block: dict[str, Any] = {"type": block_type} + if block_type == "thinking": + content_block["thinking"] = kwargs.get("thinking", "") + elif block_type == "text": + content_block["text"] = kwargs.get("text", "") + elif block_type == "tool_use": + content_block["id"] = kwargs.get("id", "") + content_block["name"] = kwargs.get("name", "") + content_block["input"] = kwargs.get("input", {}) + extra_content = kwargs.get("extra_content") + if isinstance(extra_content, dict) and extra_content: + content_block["extra_content"] = extra_content + elif block_type == "redacted_thinking": + data = kwargs.get("data") + if isinstance(data, str): + content_block["data"] = data + + self._record_block_start(index, content_block) + return self._emitter.event( + "content_block_start", + { + "type": "content_block_start", + "index": index, + "content_block": content_block, + }, + ) + + def content_block_delta(self, index: int, delta_type: str, content: str) -> str: + delta: dict[str, Any] = {"type": delta_type} + if delta_type == "thinking_delta": + delta["thinking"] = content + elif delta_type == "signature_delta": + delta["signature"] = content + elif delta_type == "text_delta": + delta["text"] = content + elif delta_type == "input_json_delta": + delta["partial_json"] = content + + self._record_block_delta(index, delta) + return self._emitter.event( + "content_block_delta", + { + "type": "content_block_delta", + "index": index, + "delta": delta, + }, + ) + + def content_block_stop(self, index: int) -> str: + self._record_block_stop(index) + return self._emitter.event( + "content_block_stop", + {"type": "content_block_stop", "index": index}, + ) + + def start_thinking_block(self) -> str: + self.blocks.thinking_index = self.blocks.allocate_index() + self.blocks.thinking_started = True + return self.content_block_start(self.blocks.thinking_index, "thinking") + + def emit_thinking_delta(self, content: str) -> str: + return self.content_block_delta( + self.blocks.thinking_index, "thinking_delta", content + ) + + def stop_thinking_block(self) -> str: + self.blocks.thinking_started = False + return self.content_block_stop(self.blocks.thinking_index) + + def start_text_block(self) -> str: + self.blocks.text_index = self.blocks.allocate_index() + self.blocks.text_started = True + return self.content_block_start(self.blocks.text_index, "text") + + def emit_text_delta(self, content: str) -> str: + return self.content_block_delta(self.blocks.text_index, "text_delta", content) + + def stop_text_block(self) -> str: + self.blocks.text_started = False + return self.content_block_stop(self.blocks.text_index) + + def start_tool_block( + self, + tool_index: int, + tool_id: str, + name: str, + *, + extra_content: dict[str, Any] | None = None, + ) -> str: + block_idx = self.blocks.allocate_index() + if tool_index in self.blocks.tool_states: + state = self.blocks.tool_states[tool_index] + state.block_index = block_idx + state.tool_id = tool_id + state.name = name + if extra_content: + state.extra_content = extra_content + state.started = True + else: + self.blocks.tool_states[tool_index] = ToolBlockState( + block_index=block_idx, + tool_id=tool_id, + name=name, + extra_content=extra_content, + started=True, + ) + return self.content_block_start( + block_idx, + "tool_use", + id=tool_id, + name=name, + extra_content=extra_content, + ) + + def emit_tool_delta(self, tool_index: int, partial_json: str) -> str: + state = self.blocks.tool_states[tool_index] + return self.content_block_delta( + state.block_index, "input_json_delta", partial_json + ) + + def stop_tool_block(self, tool_index: int) -> str: + return self.content_block_stop(self.blocks.tool_states[tool_index].block_index) + + def ensure_thinking_block(self) -> Iterator[str]: + if self.blocks.text_started: + yield self.stop_text_block() + if not self.blocks.thinking_started: + yield self.start_thinking_block() + + def ensure_text_block(self) -> Iterator[str]: + if self.blocks.thinking_started: + yield self.stop_thinking_block() + if not self.blocks.text_started: + yield self.start_text_block() + + def close_content_blocks(self) -> Iterator[str]: + if self.blocks.thinking_started: + yield self.stop_thinking_block() + if self.blocks.text_started: + yield self.stop_text_block() + + def close_all_blocks(self) -> Iterator[str]: + yield from self.close_content_blocks() + for tool_index, state in list(self.blocks.tool_states.items()): + if state.started: + yield self.stop_tool_block(tool_index) + + def close_unclosed_blocks(self) -> Iterator[str]: + while self._open_stack: + idx = self._open_stack.pop() + state = self._content_blocks.get(idx) + if state is not None: + state.open = False + self._clear_active_content_block(state) + yield self._emitter.event( + "content_block_stop", + {"type": "content_block_stop", "index": idx}, + ) + + def can_salvage_tool_use(self, schemas: dict[str, ToolSchema]) -> bool: + tool_blocks = self.tool_blocks() + if not tool_blocks: + return False + for block in tool_blocks: + if not block.tool_id or not block.name: + return False + if parse_complete_tool_input(block.content, block.name, schemas) is None: + return False + return True + + def tool_blocks(self) -> list[StreamBlockState]: + return [ + block + for block in self._content_blocks.values() + if block.block_type == "tool_use" + ] + + def tool_block_for_tool_index(self, tool_index: int) -> StreamBlockState | None: + state = self.blocks.tool_states.get(tool_index) + if state is None or state.block_index < 0: + return None + block = self._content_blocks.get(state.block_index) + if block is None or block.block_type != "tool_use": + return None + return block + + def has_emitted_tool_block(self) -> bool: + return bool(self.tool_blocks()) + + def has_content_block(self) -> bool: + return bool(self._content_blocks) + + def final_stop_reason(self, fallback: str) -> str: + if self.has_emitted_tool_block(): + return "tool_use" + return fallback + + def has_terminal_message(self) -> bool: + return self.message_stopped + + @property + def accumulated_text(self) -> str: + return "".join(self._text_parts) + + @property + def accumulated_reasoning(self) -> str: + return "".join(self._thinking_parts) + + def estimate_output_tokens(self) -> int: + if ENCODER: + text_tokens = len(ENCODER.encode(self.accumulated_text)) + reasoning_tokens = len(ENCODER.encode(self.accumulated_reasoning)) + tool_tokens = 0 + tool_count = 0 + for name, content in self._iter_tool_token_payloads(): + tool_tokens += len(ENCODER.encode(name)) + tool_tokens += len(ENCODER.encode(content)) + tool_tokens += 15 + tool_count += 1 + + block_count = ( + (1 if self.accumulated_reasoning else 0) + + (1 if self.accumulated_text else 0) + + tool_count + ) + return text_tokens + reasoning_tokens + tool_tokens + (block_count * 4) + + text_tokens = len(self.accumulated_text) // 4 + reasoning_tokens = len(self.accumulated_reasoning) // 4 + tool_tokens = sum(1 for _ in self._iter_tool_token_payloads()) * 50 + return text_tokens + reasoning_tokens + tool_tokens + + def _iter_tool_token_payloads(self) -> Iterator[tuple[str, str]]: + for block in self.tool_blocks(): + yield block.name, block.content + + def _record_block_start(self, index: int, block: dict[str, Any]) -> None: + self.blocks.reserve_index(index) + block_type = str(block.get("type", "")) + state = StreamBlockState(index=index, block_type=block_type) + if block_type == "tool_use": + tool_id = block.get("id") + name = block.get("name") + state.tool_id = tool_id if isinstance(tool_id, str) else "" + state.name = name if isinstance(name, str) else "" + extra_content = block.get("extra_content") + state.extra_content = ( + extra_content if isinstance(extra_content, dict) else None + ) + elif block_type == "text": + self.blocks.text_index = index + self.blocks.text_started = True + text = block.get("text") + if isinstance(text, str) and text: + state.parts.append(text) + self._text_parts.append(text) + elif block_type == "thinking": + self.blocks.thinking_index = index + self.blocks.thinking_started = True + thinking = block.get("thinking") + if isinstance(thinking, str) and thinking: + state.parts.append(thinking) + self._thinking_parts.append(thinking) + self._content_blocks[index] = state + self._open_stack.append(index) + + def _record_block_delta(self, index: int, delta: dict[str, Any]) -> None: + state = self._content_blocks.get(index) + if state is None: + return + if state.block_type == "text": + text = delta.get("text") + if isinstance(text, str): + state.parts.append(text) + self._text_parts.append(text) + elif state.block_type == "thinking": + thinking = delta.get("thinking") + if isinstance(thinking, str): + state.parts.append(thinking) + self._thinking_parts.append(thinking) + elif state.block_type == "tool_use": + partial = delta.get("partial_json") + if isinstance(partial, str): + state.parts.append(partial) + + def _record_block_stop(self, index: int) -> None: + if self._open_stack and self._open_stack[-1] == index: + self._open_stack.pop() + else: + with suppress(ValueError): + self._open_stack.remove(index) + state = self._content_blocks.get(index) + if state is not None: + state.open = False + self._clear_active_content_block(state) + + def _last_open_block(self, block_type: str) -> StreamBlockState | None: + for index in reversed(self._open_stack): + block = self._content_blocks.get(index) + if block is not None and block.block_type == block_type and block.open: + return block + return None + + def _clear_active_content_block(self, state: StreamBlockState) -> None: + if state.block_type == "text" and self.blocks.text_index == state.index: + self.blocks.text_started = False + elif ( + state.block_type == "thinking" and self.blocks.thinking_index == state.index + ): + self.blocks.thinking_started = False + + +def _normalize_task_run_in_background(args_json: dict[str, Any]) -> None: + if args_json.get("run_in_background") is not False: + args_json["run_in_background"] = False diff --git a/src/free_claude_code/core/anthropic/streaming/recovery.py b/src/free_claude_code/core/anthropic/streaming/recovery.py new file mode 100644 index 0000000..e488edc --- /dev/null +++ b/src/free_claude_code/core/anthropic/streaming/recovery.py @@ -0,0 +1,203 @@ +"""Pure Anthropic continuation and tool-repair transformations.""" + +import json +from copy import deepcopy +from dataclasses import dataclass +from typing import Any + +import jsonschema +from loguru import logger + +from ..models import MessagesRequest + +_RECOVERY_USER_PREFIX = ( + "The previous provider stream was interrupted. Continue the assistant response " + "exactly where it stopped. Do not repeat text already written." +) +_RECOVERY_THINKING_PREFIX = ( + "The assistant had already emitted this hidden thinking before the interruption:\n" +) + + +@dataclass(frozen=True, slots=True) +class ToolSchema: + """Tool schema resolved from the original Anthropic request.""" + + name: str + input_schema: dict[str, Any] + + +@dataclass(frozen=True, slots=True) +class ToolRepair: + """Accepted append-only tool JSON repair.""" + + suffix: str + parsed_input: dict[str, Any] + + +def tool_schemas_by_name(request: MessagesRequest) -> dict[str, ToolSchema]: + """Return Anthropic tool input schemas keyed by tool name.""" + schemas: dict[str, ToolSchema] = {} + tools = request.tools + if not tools: + return schemas + + for tool in tools: + name = tool.name + if not name: + continue + schema = tool.input_schema + if schema is None: + schema = {"type": "object"} + schemas[name] = ToolSchema(name=name, input_schema=deepcopy(schema)) + return schemas + + +def validate_tool_input( + tool_name: str, parsed_input: dict[str, Any], schemas: dict[str, ToolSchema] +) -> bool: + tool_schema = schemas.get(tool_name) + if tool_schema is None: + return True + try: + validator_cls = jsonschema.validators.validator_for(tool_schema.input_schema) + validator_cls.check_schema(tool_schema.input_schema) + validator_cls(tool_schema.input_schema).validate(parsed_input) + except jsonschema.exceptions.SchemaError as exc: + logger.warning("Skipping invalid tool schema for {}: {}", tool_name, exc) + return True + except jsonschema.exceptions.ValidationError: + return False + return True + + +def parse_complete_tool_input( + raw_json: str, tool_name: str, schemas: dict[str, ToolSchema] +) -> dict[str, Any] | None: + try: + parsed = json.loads(raw_json) + except json.JSONDecodeError: + return None + if not isinstance(parsed, dict): + return None + if not validate_tool_input(tool_name, parsed, schemas): + return None + return parsed + + +def accept_tool_json_repair( + prefix: str, + candidate: str, + *, + tool_name: str, + schemas: dict[str, ToolSchema], +) -> ToolRepair | None: + for suffix in _repair_suffix_candidates(prefix, candidate): + combined = prefix + suffix + parsed = parse_complete_tool_input(combined, tool_name, schemas) + if parsed is not None: + return ToolRepair(suffix=suffix, parsed_input=parsed) + return None + + +def continuation_suffix(existing: str, candidate: str) -> str | None: + existing = existing or "" + candidate = candidate or "" + if not candidate: + return "" + if not existing: + return candidate + if candidate.startswith(existing): + return candidate[len(existing) :] + + max_overlap = min(len(existing), len(candidate)) + for size in range(max_overlap, 0, -1): + if existing.endswith(candidate[:size]): + return candidate[size:] + + if len(candidate) < max(200, len(existing) // 2): + return candidate + return None + + +def make_text_recovery_body( + body: dict[str, Any], + partial_text: str, + partial_thinking: str = "", +) -> dict[str, Any]: + """Build a text-only continuation request for an OpenAI-chat upstream.""" + recovery = deepcopy(body) + recovery.pop("tools", None) + recovery.pop("tool_choice", None) + recovery["stream"] = True + messages = _copied_messages(recovery) + if partial_text: + messages.append({"role": "assistant", "content": partial_text}) + prompt = _RECOVERY_USER_PREFIX + if partial_thinking: + prompt = f"{_RECOVERY_THINKING_PREFIX}{partial_thinking}\n\n{prompt}" + messages.append({"role": "user", "content": prompt}) + recovery["messages"] = messages + return recovery + + +def make_tool_repair_body( + body: dict[str, Any], + *, + tool_name: str, + prefix: str, + input_schema: dict[str, Any] | None, +) -> dict[str, Any]: + """Build a text-only request asking for a JSON suffix.""" + recovery = deepcopy(body) + recovery.pop("tools", None) + recovery.pop("tool_choice", None) + recovery["stream"] = True + messages = _copied_messages(recovery) + messages.append( + { + "role": "user", + "content": _tool_repair_prompt( + tool_name=tool_name, prefix=prefix, input_schema=input_schema + ), + } + ) + recovery["messages"] = messages + return recovery + + +def _copied_messages(body: dict[str, Any]) -> list[Any]: + messages = body.get("messages") + return deepcopy(messages) if isinstance(messages, list) else [] + + +def _repair_suffix_candidates(prefix: str, candidate: str) -> list[str]: + raw = candidate.strip() + if not raw: + return [] + candidates: list[str] = [] + if raw.startswith("```"): + lines = raw.splitlines() + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + raw = "\n".join(lines).strip() + candidates.append(raw) + if raw.startswith(prefix): + candidates.append(raw[len(prefix) :]) + return list(dict.fromkeys(candidates)) + + +def _tool_repair_prompt( + *, tool_name: str, prefix: str, input_schema: dict[str, Any] | None +) -> str: + schema_text = json.dumps(input_schema or {"type": "object"}, separators=(",", ":")) + return ( + "A streamed tool call was interrupted while writing JSON arguments.\n" + f"Tool name: {tool_name}\n" + f"JSON schema: {schema_text}\n" + f"Already emitted JSON prefix: {prefix}\n\n" + "Return only the exact missing JSON suffix needed to complete the same object. " + "Do not repeat the prefix. Do not include markdown or explanation." + ) diff --git a/src/free_claude_code/core/anthropic/thinking.py b/src/free_claude_code/core/anthropic/thinking.py new file mode 100644 index 0000000..fa793d1 --- /dev/null +++ b/src/free_claude_code/core/anthropic/thinking.py @@ -0,0 +1,140 @@ +"""Streaming parser for provider-emitted thinking tags.""" + +from collections.abc import Iterator +from dataclasses import dataclass +from enum import Enum + + +class ContentType(Enum): + """Type of content chunk.""" + + TEXT = "text" + THINKING = "thinking" + + +@dataclass +class ContentChunk: + """A chunk of parsed content.""" + + type: ContentType + content: str + + +class ThinkTagParser: + """ + Streaming parser for ``...`` tags. + + Handles partial tags at chunk boundaries by buffering. + """ + + OPEN_TAG = "" + CLOSE_TAG = "" + + def __init__(self): + self._buffer: str = "" + self._in_think_tag: bool = False + + @property + def in_think_mode(self) -> bool: + """Whether currently inside a think tag.""" + return self._in_think_tag + + def feed(self, content: str) -> Iterator[ContentChunk]: + """Feed content and yield parsed chunks.""" + self._buffer += content + + while self._buffer: + prev_len = len(self._buffer) + if not self._in_think_tag: + chunk = self._parse_outside_think() + else: + chunk = self._parse_inside_think() + + if chunk: + yield chunk + elif len(self._buffer) == prev_len: + break + + def _parse_outside_think(self) -> ContentChunk | None: + """Parse content outside think tags.""" + think_start = self._buffer.find(self.OPEN_TAG) + orphan_close = self._buffer.find(self.CLOSE_TAG) + + if orphan_close != -1 and (think_start == -1 or orphan_close < think_start): + pre_orphan = self._buffer[:orphan_close] + self._buffer = self._buffer[orphan_close + len(self.CLOSE_TAG) :] + if pre_orphan: + return ContentChunk(ContentType.TEXT, pre_orphan) + return None + + if think_start == -1: + last_bracket = self._buffer.rfind("<") + if last_bracket != -1: + potential_tag = self._buffer[last_bracket:] + tag_len = len(potential_tag) + if ( + tag_len < len(self.OPEN_TAG) + and self.OPEN_TAG.startswith(potential_tag) + ) or ( + tag_len < len(self.CLOSE_TAG) + and self.CLOSE_TAG.startswith(potential_tag) + ): + emit = self._buffer[:last_bracket] + self._buffer = self._buffer[last_bracket:] + if emit: + return ContentChunk(ContentType.TEXT, emit) + return None + + emit = self._buffer + self._buffer = "" + if emit: + return ContentChunk(ContentType.TEXT, emit) + return None + + pre_think = self._buffer[:think_start] + self._buffer = self._buffer[think_start + len(self.OPEN_TAG) :] + self._in_think_tag = True + if pre_think: + return ContentChunk(ContentType.TEXT, pre_think) + return None + + def _parse_inside_think(self) -> ContentChunk | None: + """Parse content inside think tags.""" + think_end = self._buffer.find(self.CLOSE_TAG) + + if think_end == -1: + last_bracket = self._buffer.rfind("<") + if last_bracket != -1 and len(self._buffer) - last_bracket < len( + self.CLOSE_TAG + ): + potential_tag = self._buffer[last_bracket:] + if self.CLOSE_TAG.startswith(potential_tag): + emit = self._buffer[:last_bracket] + self._buffer = self._buffer[last_bracket:] + if emit: + return ContentChunk(ContentType.THINKING, emit) + return None + + emit = self._buffer + self._buffer = "" + if emit: + return ContentChunk(ContentType.THINKING, emit) + return None + + thinking_content = self._buffer[:think_end] + self._buffer = self._buffer[think_end + len(self.CLOSE_TAG) :] + self._in_think_tag = False + if thinking_content: + return ContentChunk(ContentType.THINKING, thinking_content) + return None + + def flush(self) -> ContentChunk | None: + """Flush any remaining buffered content.""" + if self._buffer: + chunk_type = ( + ContentType.THINKING if self._in_think_tag else ContentType.TEXT + ) + content = self._buffer + self._buffer = "" + return ContentChunk(chunk_type, content) + return None diff --git a/src/free_claude_code/core/anthropic/tokens.py b/src/free_claude_code/core/anthropic/tokens.py new file mode 100644 index 0000000..5fc1750 --- /dev/null +++ b/src/free_claude_code/core/anthropic/tokens.py @@ -0,0 +1,118 @@ +"""Token estimation for Anthropic-compatible requests.""" + +import json + +import tiktoken +from loguru import logger + +from .content import get_block_attr +from .models import Message, SystemContent, Tool + +ENCODER = tiktoken.get_encoding("cl100k_base") + +_DISALLOWED_SPECIAL: tuple[str, ...] = () + + +def _count_text_tokens(text: str) -> int: + return len(ENCODER.encode(text, disallowed_special=_DISALLOWED_SPECIAL)) + + +def get_token_count( + messages: list[Message], + system: str | list[SystemContent] | None = None, + tools: list[Tool] | None = None, +) -> int: + """Estimate token count for a request.""" + total_tokens = 0 + + if system: + if isinstance(system, str): + total_tokens += _count_text_tokens(system) + elif isinstance(system, list): + for block in system: + text = get_block_attr(block, "text", "") + if text: + total_tokens += _count_text_tokens(str(text)) + total_tokens += 4 + + for msg in messages: + if isinstance(msg.content, str): + total_tokens += _count_text_tokens(msg.content) + elif isinstance(msg.content, list): + for block in msg.content: + b_type = get_block_attr(block, "type") or None + + if b_type == "text": + text = get_block_attr(block, "text", "") + total_tokens += _count_text_tokens(str(text)) + elif b_type == "thinking": + thinking = get_block_attr(block, "thinking", "") + total_tokens += _count_text_tokens(str(thinking)) + elif b_type == "tool_use": + name = get_block_attr(block, "name", "") + inp = get_block_attr(block, "input", {}) + block_id = get_block_attr(block, "id", "") + total_tokens += _count_text_tokens(str(name)) + total_tokens += _count_text_tokens(json.dumps(inp)) + total_tokens += _count_text_tokens(str(block_id)) + total_tokens += 15 + elif b_type == "image": + source = get_block_attr(block, "source") + if isinstance(source, dict): + data = source.get("data") or source.get("base64") or "" + if data: + total_tokens += max(85, len(data) // 3000) + else: + total_tokens += 765 + else: + total_tokens += 765 + elif b_type == "tool_result": + content = get_block_attr(block, "content", "") + tool_use_id = get_block_attr(block, "tool_use_id", "") + if isinstance(content, str): + total_tokens += _count_text_tokens(content) + else: + total_tokens += _count_text_tokens(json.dumps(content)) + total_tokens += _count_text_tokens(str(tool_use_id)) + total_tokens += 8 + elif b_type in ( + "server_tool_use", + "web_search_tool_result", + "web_fetch_tool_result", + ): + if hasattr(block, "model_dump"): + blob: object = block.model_dump() + else: + blob = block + try: + total_tokens += _count_text_tokens( + json.dumps(blob, default=str, ensure_ascii=False) + ) + except (TypeError, ValueError, OverflowError) as e: + logger.debug( + "Block encode fallback b_type={} err={}", b_type, e + ) + total_tokens += _count_text_tokens(str(blob)) + total_tokens += 12 + else: + logger.debug( + "Unexpected block type %r, falling back to json/str encoding", + b_type, + ) + try: + total_tokens += _count_text_tokens(json.dumps(block)) + except TypeError, ValueError: + total_tokens += _count_text_tokens(str(block)) + + if tools: + for tool in tools: + tool_str = ( + tool.name + (tool.description or "") + json.dumps(tool.input_schema) + ) + total_tokens += _count_text_tokens(tool_str) + + total_tokens += len(messages) * 4 + if tools: + total_tokens += len(tools) * 5 + + return max(1, total_tokens) diff --git a/src/free_claude_code/core/anthropic/tools.py b/src/free_claude_code/core/anthropic/tools.py new file mode 100644 index 0000000..c09beb5 --- /dev/null +++ b/src/free_claude_code/core/anthropic/tools.py @@ -0,0 +1,212 @@ +"""Heuristic parser for text-emitted tool calls.""" + +import json +import re +import uuid +from enum import Enum +from typing import Any + +from loguru import logger + +_CONTROL_TOKEN_RE = re.compile(r"<\|[^|>]{1,80}\|>") +_CONTROL_TOKEN_START = "<|" +_CONTROL_TOKEN_END = "|>" + + +class ParserState(Enum): + TEXT = 1 + MATCHING_FUNCTION = 2 + PARSING_PARAMETERS = 3 + + +class HeuristicToolParser: + """ + Stateful parser for raw text tool calls. + + Some OpenAI-compatible models emit tool calls as text rather than structured + chunks. This parser converts the common ``● `` form into + Anthropic-style ``tool_use`` blocks. + """ + + _FUNC_START_PATTERN = re.compile(r"●\s*]+)>") + _PARAM_PATTERN = re.compile( + r"]+)>(.*?)(?:|$)", re.DOTALL + ) + _WEB_TOOL_JSON_PATTERN = re.compile( + r"(?is)\b(?:use\s+)?(?PWebFetch|WebSearch)\b.*?(?P\{.*?\})" + ) + + def __init__(self): + self._state = ParserState.TEXT + self._buffer = "" + self._current_tool_id = None + self._current_function_name = None + self._current_parameters = {} + + def _extract_web_tool_json_calls(self) -> tuple[str, list[dict[str, Any]]]: + detected_tools: list[dict[str, Any]] = [] + + for match in self._WEB_TOOL_JSON_PATTERN.finditer(self._buffer): + try: + tool_input = json.loads(match.group("json")) + except json.JSONDecodeError: + continue + if not isinstance(tool_input, dict): + continue + + tool_name = match.group("tool") + if tool_name == "WebFetch" and "url" not in tool_input: + continue + if tool_name == "WebSearch" and "query" not in tool_input: + continue + + detected_tools.append( + { + "type": "tool_use", + "id": f"toolu_heuristic_{uuid.uuid4().hex[:8]}", + "name": tool_name, + "input": tool_input, + } + ) + logger.debug( + "Heuristic bypass: Detected JSON-style tool call '{}'", + tool_name, + ) + + if not detected_tools: + return self._buffer, [] + + return "", detected_tools + + def _strip_control_tokens(self, text: str) -> str: + return _CONTROL_TOKEN_RE.sub("", text) + + def _split_incomplete_control_token_tail(self) -> str: + start = self._buffer.rfind(_CONTROL_TOKEN_START) + if start == -1: + return "" + end = self._buffer.find(_CONTROL_TOKEN_END, start) + if end != -1: + return "" + + prefix = self._buffer[:start] + self._buffer = self._buffer[start:] + return prefix + + def feed(self, text: str) -> tuple[str, list[dict[str, Any]]]: + """Feed text and return safe text plus detected tool calls.""" + self._buffer += text + self._buffer = self._strip_control_tokens(self._buffer) + self._buffer, detected_tools = self._extract_web_tool_json_calls() + filtered_output_parts: list[str] = [] + + while True: + if self._state == ParserState.TEXT: + if "●" in self._buffer: + idx = self._buffer.find("●") + filtered_output_parts.append(self._buffer[:idx]) + self._buffer = self._buffer[idx:] + self._state = ParserState.MATCHING_FUNCTION + else: + safe_prefix = self._split_incomplete_control_token_tail() + if safe_prefix: + filtered_output_parts.append(safe_prefix) + break + + filtered_output_parts.append(self._buffer) + self._buffer = "" + break + + if self._state == ParserState.MATCHING_FUNCTION: + match = self._FUNC_START_PATTERN.search(self._buffer) + if match: + self._current_function_name = match.group(1).strip() + self._current_tool_id = f"toolu_heuristic_{uuid.uuid4().hex[:8]}" + self._current_parameters = {} + self._buffer = self._buffer[match.end() :] + self._state = ParserState.PARSING_PARAMETERS + logger.debug( + "Heuristic bypass: Detected start of tool call '{}'", + self._current_function_name, + ) + elif len(self._buffer) > 100: + filtered_output_parts.append(self._buffer[0]) + self._buffer = self._buffer[1:] + self._state = ParserState.TEXT + else: + break + + if self._state == ParserState.PARSING_PARAMETERS: + finished_tool_call = False + + while True: + param_match = self._PARAM_PATTERN.search(self._buffer) + if param_match and "" in param_match.group(0): + pre_match_text = self._buffer[: param_match.start()] + if pre_match_text: + filtered_output_parts.append(pre_match_text) + + key = param_match.group(1).strip() + val = param_match.group(2).strip() + self._current_parameters[key] = val + self._buffer = self._buffer[param_match.end() :] + else: + break + + if "●" in self._buffer: + idx = self._buffer.find("●") + if idx > 0: + filtered_output_parts.append(self._buffer[:idx]) + self._buffer = self._buffer[idx:] + finished_tool_call = True + elif len(self._buffer) > 0 and not self._buffer.strip().startswith("<"): + if " list[dict[str, Any]]: + """Flush any remaining tool call in the buffer.""" + self._buffer = self._strip_control_tokens(self._buffer) + detected_tools = [] + if self._state == ParserState.PARSING_PARAMETERS: + partial_matches = re.finditer( + r"]+)>(.*)$", self._buffer, re.DOTALL + ) + for match in partial_matches: + key = match.group(1).strip() + val = match.group(2).strip() + self._current_parameters[key] = val + + detected_tools.append( + { + "type": "tool_use", + "id": self._current_tool_id, + "name": self._current_function_name, + "input": self._current_parameters, + } + ) + self._state = ParserState.TEXT + self._buffer = "" + + return detected_tools diff --git a/src/free_claude_code/core/anthropic/utils.py b/src/free_claude_code/core/anthropic/utils.py new file mode 100644 index 0000000..84d33de --- /dev/null +++ b/src/free_claude_code/core/anthropic/utils.py @@ -0,0 +1,9 @@ +"""Small shared protocol utility helpers.""" + +from typing import Any + + +def set_if_not_none(body: dict[str, Any], key: str, value: Any) -> None: + """Set ``body[key]`` only when value is not None.""" + if value is not None: + body[key] = value diff --git a/src/free_claude_code/core/async_iterators.py b/src/free_claude_code/core/async_iterators.py new file mode 100644 index 0000000..589ac26 --- /dev/null +++ b/src/free_claude_code/core/async_iterators.py @@ -0,0 +1,26 @@ +"""Minimal lifecycle helpers for composed asynchronous iterators.""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class AsyncCloseable(Protocol): + """An object whose asynchronous iteration resources can be released.""" + + async def aclose(self) -> None: ... + + +async def try_close_async_iterator(value: object) -> Exception | None: + """Close ``value`` when supported, returning ordinary cleanup failures. + + Cancellation remains control flow and propagates to the caller. Returning + ordinary exceptions lets an owner observe cleanup failure without replacing + the stream outcome that was already established. + """ + if not isinstance(value, AsyncCloseable): + return None + try: + await value.aclose() + except Exception as exc: + return exc + return None diff --git a/src/free_claude_code/core/diagnostics.py b/src/free_claude_code/core/diagnostics.py new file mode 100644 index 0000000..1335dd7 --- /dev/null +++ b/src/free_claude_code/core/diagnostics.py @@ -0,0 +1,301 @@ +"""Credential-safe diagnostics shared across product boundaries.""" + +import json +import re +import traceback +from dataclasses import dataclass +from typing import Any + +from .failures import ExecutionFailure + +ERROR_DETAIL_DISPLAY_CAP_BYTES = 16_384 +_MAX_CAUSE_CHAIN_DEPTH = 4 +_UPSTREAM_BODY_ATTR = "_fcc_upstream_error_body" +_UPSTREAM_BODY_TRUNCATED_ATTR = "_fcc_upstream_error_body_truncated" + +_SECRET_TEXT_REPLACEMENTS = ( + ( + re.compile( + r"(?i)(?P[\"']?authorization[\"']?\s*[:=]\s*)" + r"(?P[\"']?)(?:(?:bearer|basic)\s+)?" + r"[^\"'\s,;&}\]]+(?P=quote)" + ), + r"\g\g\g", + ), + ( + re.compile( + r"(?i)(?P[\"']?(?:api[_-]?key|access[_-]?token|" + r"refresh[_-]?token|token|client[_-]?secret|secret|password)" + r"[\"']?\s*[:=]\s*)(?P[\"']?)" + r"[^\"'\s,;&}\]]+(?P=quote)" + ), + r"\g\g\g", + ), + (re.compile(r"(?i)(bearer\s+)[^\s,;]+"), r"\1"), + ( + re.compile( + r"(?i)(?", + ), +) + + +@dataclass(frozen=True, slots=True) +class UpstreamErrorDetail: + """Sanitized diagnostic detail extracted from an upstream exception.""" + + status_code: int | None = None + body_text: str | None = None + exception_text: str | None = None + cause_chain_text: str | None = None + category_hint: str | None = None + body_truncated: bool = False + + +def redact_sensitive_error_text(text: str) -> str: + """Redact recognizable credentials while preserving diagnostic context.""" + sanitized = text + for pattern, replacement in _SECRET_TEXT_REPLACEMENTS: + sanitized = pattern.sub(replacement, sanitized) + return sanitized + + +def safe_exception_message( + exc: BaseException, + *, + fallback: str = "Provider request failed unexpectedly.", +) -> str: + """Return a redacted, non-empty exception message.""" + message = redact_sensitive_error_text(str(exc).strip()) + return message or fallback + + +def format_user_error_preview(exc: BaseException, *, max_len: int = 200) -> str: + """Return a short redacted exception preview for chat surfaces.""" + return safe_exception_message(exc)[:max_len] + + +def attach_upstream_error_body( + exc: Exception, + body: bytes | str, + *, + truncated: bool = False, +) -> None: + """Attach a bounded streamed response body for later safe formatting.""" + setattr(exc, _UPSTREAM_BODY_ATTR, body) + setattr(exc, _UPSTREAM_BODY_TRUNCATED_ATTR, truncated) + + +def exception_cause_types(exc: BaseException) -> tuple[str, ...]: + """Return exception cause type names without logging their contents.""" + return tuple(type(cause).__name__ for cause in _exception_causes(exc)) + + +def redacted_exception_traceback(exc: BaseException) -> str: + """Format a traceback while redacting recognizable credentials.""" + return redact_sensitive_error_text("".join(traceback.format_exception(exc))) + + +def extract_upstream_error_detail(exc: Exception) -> UpstreamErrorDetail: + """Extract bounded, redacted body, exception, and cause-chain details.""" + raw_body = getattr(exc, _UPSTREAM_BODY_ATTR, None) + body_truncated = bool(getattr(exc, _UPSTREAM_BODY_TRUNCATED_ATTR, False)) + if raw_body is None: + raw_body = getattr(exc, "body", None) + if raw_body is None: + raw_body = _body_from_response(exc) + + body_text = _normalize_body_text(raw_body) + if body_text is not None: + body_text = redact_sensitive_error_text(body_text) + body_text, capped = _cap_text_bytes(body_text) + body_truncated = body_truncated or capped + + exception_text = str(exc).strip() or None + if exception_text is not None: + exception_text = redact_sensitive_error_text(exception_text) + exception_text, _ = _cap_text_bytes(exception_text) + + return UpstreamErrorDetail( + status_code=_status_code_from_exception(exc), + body_text=body_text, + exception_text=exception_text, + cause_chain_text=_exception_cause_chain_text(exc), + category_hint=_category_hint_from_body(raw_body, body_text), + body_truncated=body_truncated, + ) + + +def format_execution_failure_message( + failure: ExecutionFailure, + detail: UpstreamErrorDetail, + *, + upstream_name: str, + request_id: str | None = None, +) -> str: + """Build a copyable, redacted diagnostic for a finalized execution failure.""" + stable_message = failure.message + has_upstream_detail = detail.status_code is not None or detail.body_text is not None + if not has_upstream_detail: + lines = [stable_message] + if detail.exception_text and detail.exception_text != stable_message: + lines.extend(("", "Provider exception:", detail.exception_text)) + if detail.cause_chain_text: + lines.extend(("", "Caused by:", detail.cause_chain_text)) + _append_request_id_lines(lines, request_id) + return "\n".join(lines) + + if detail.status_code == 405: + lines = [ + f"Upstream provider {upstream_name} rejected the request method " + "or endpoint (HTTP 405)." + ] + elif detail.status_code is not None: + lines = [ + f"Upstream provider {upstream_name} returned HTTP {detail.status_code}." + ] + else: + lines = [f"Upstream provider {upstream_name} returned an error."] + + lines.append(f"Category: {detail.category_hint or failure.kind.value}") + if stable_message and stable_message != lines[0]: + lines.append(f"Mapped message: {stable_message}") + lines.extend(("", "Upstream error:")) + lines.append(detail.body_text or "(empty upstream error body)") + if _body_truncation_line_needed(detail): + lines.append(f"... [truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes]") + _append_request_id_lines(lines, request_id) + return "\n".join(lines) + + +def _body_truncation_line_needed(detail: UpstreamErrorDetail) -> bool: + """Return whether a separate truncation marker is needed.""" + return detail.body_truncated and ( + detail.body_text is None + or f"truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes" + not in detail.body_text + ) + + +def _status_code_from_exception(exc: Exception) -> int | None: + status = getattr(exc, "status_code", None) + if isinstance(status, int): + return status + response = getattr(exc, "response", None) + response_status = getattr(response, "status_code", None) + return response_status if isinstance(response_status, int) else None + + +def _body_from_response(exc: Exception) -> Any: + response = getattr(exc, "response", None) + if response is None: + return None + try: + return response.json() + except Exception: + pass + try: + return response.text + except Exception: + return None + + +def _normalize_body_text(body: Any) -> str | None: + if body is None: + return None + if isinstance(body, bytes): + text = body.decode("utf-8", errors="replace") + elif isinstance(body, str): + text = body + else: + try: + return json.dumps(body, ensure_ascii=False, separators=(",", ":")) + except TypeError: + text = str(body) + stripped = text.strip() + if not stripped: + return None + try: + parsed = json.loads(stripped) + except ValueError: + return stripped + return json.dumps(parsed, ensure_ascii=False, separators=(",", ":")) + + +def _cap_text_bytes(text: str) -> tuple[str, bool]: + encoded = text.encode("utf-8", errors="replace") + if len(encoded) <= ERROR_DETAIL_DISPLAY_CAP_BYTES: + return text, False + capped = encoded[:ERROR_DETAIL_DISPLAY_CAP_BYTES].decode("utf-8", errors="replace") + return ( + f"{capped}\n... [truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes]", + True, + ) + + +def _exception_causes(exc: BaseException) -> tuple[BaseException, ...]: + causes: list[BaseException] = [] + seen = {id(exc)} + current: BaseException | None = exc + while current is not None and len(causes) < _MAX_CAUSE_CHAIN_DEPTH: + next_exc = current.__cause__ or current.__context__ + if next_exc is None or id(next_exc) in seen: + break + seen.add(id(next_exc)) + causes.append(next_exc) + current = next_exc + return tuple(causes) + + +def _exception_cause_chain_text(exc: BaseException) -> str | None: + lines: list[str] = [] + for cause in _exception_causes(exc): + raw_text = str(cause).strip() + lines.append( + f"{type(cause).__name__}: {redact_sensitive_error_text(raw_text)}" + if raw_text + else type(cause).__name__ + ) + if not lines: + return None + text, _ = _cap_text_bytes("\n".join(lines)) + return text + + +def _category_hint_from_body(body: Any, body_text: str | None) -> str | None: + parsed = body + if isinstance(parsed, bytes): + parsed = parsed.decode("utf-8", errors="replace") + if isinstance(parsed, str): + try: + parsed = json.loads(parsed) + except ValueError: + parsed = None + if isinstance(parsed, dict): + error = parsed.get("error") + if isinstance(error, dict): + for key in ("type", "code"): + value = error.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + for key in ("type", "code"): + value = parsed.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + if ( + body_text + and "model" in body_text.lower() + and "unsupported" in body_text.lower() + ): + return "upstream_model_error" + return None + + +def _append_request_id_lines(lines: list[str], request_id: str | None) -> None: + if request_id: + lines.extend(("", f"Request ID: {request_id}")) diff --git a/src/free_claude_code/core/failures.py b/src/free_claude_code/core/failures.py new file mode 100644 index 0000000..c2dacbf --- /dev/null +++ b/src/free_claude_code/core/failures.py @@ -0,0 +1,49 @@ +"""Protocol-neutral execution failure semantics.""" + +from dataclasses import FrozenInstanceError, dataclass +from enum import StrEnum + + +class FailureKind(StrEnum): + """Stable failure categories shared across execution and wire adapters.""" + + INVALID_REQUEST = "invalid_request" + AUTHENTICATION = "authentication" + PERMISSION = "permission" + RATE_LIMIT = "rate_limit" + OVERLOADED = "overloaded" + TIMEOUT = "timeout" + UPSTREAM = "upstream" + UNAVAILABLE = "unavailable" + + +@dataclass(slots=True, eq=False) +class ExecutionFailure(Exception): + """A finalized provider-execution failure independent of any wire protocol.""" + + kind: FailureKind + status_code: int + message: str + retryable: bool + + def __post_init__(self) -> None: + Exception.__init__(self, self.message) + + def __setattr__(self, name: str, value: object) -> None: + # Exception machinery must be able to update __traceback__, __cause__, + # and __context__ while semantic failure fields remain immutable. + if name in self.__slots__ and hasattr(self, name): + raise FrozenInstanceError(f"cannot assign to field {name!r}") + super().__setattr__(name, value) + + +def find_execution_failure(exc: BaseException) -> ExecutionFailure | None: + """Return the first canonical failure in an exception or nested group.""" + pending = [exc] + while pending: + current = pending.pop() + if isinstance(current, ExecutionFailure): + return current + if isinstance(current, BaseExceptionGroup): + pending.extend(reversed(current.exceptions)) + return None diff --git a/src/free_claude_code/core/gateway_model_ids.py b/src/free_claude_code/core/gateway_model_ids.py new file mode 100644 index 0000000..c7db23e --- /dev/null +++ b/src/free_claude_code/core/gateway_model_ids.py @@ -0,0 +1,52 @@ +"""Gateway-safe model ID encoding shared by API and CLI adapters.""" + +from dataclasses import dataclass + +GATEWAY_MODEL_ID_PREFIX = "anthropic" + +# Claude Code currently treats any model id containing ``claude-3-`` as not +# supporting thinking. This intentionally uses that client-side capability +# heuristic while keeping the real provider/model ref reversible for routing. +NO_THINKING_GATEWAY_MODEL_ID_PREFIX = "claude-3-freecc-no-thinking" + + +@dataclass(frozen=True, slots=True) +class DecodedGatewayModelId: + provider_id: str + provider_model: str + force_thinking_enabled: bool | None = None + + +def gateway_model_id(provider_model_ref: str) -> str: + """Return the normal Claude Code-discoverable id for a provider/model ref.""" + return f"{GATEWAY_MODEL_ID_PREFIX}/{provider_model_ref}" + + +def no_thinking_gateway_model_id(provider_model_ref: str) -> str: + """Return a Claude Code-discoverable id that disables client thinking.""" + return f"{NO_THINKING_GATEWAY_MODEL_ID_PREFIX}/{provider_model_ref}" + + +def decode_gateway_model_id(model_name: str) -> DecodedGatewayModelId | None: + """Decode a model id advertised by this gateway, if it is one.""" + prefix, separator, remainder = model_name.partition("/") + if not separator: + return None + + force_thinking_enabled: bool | None + if prefix == GATEWAY_MODEL_ID_PREFIX: + force_thinking_enabled = None + elif prefix == NO_THINKING_GATEWAY_MODEL_ID_PREFIX: + force_thinking_enabled = False + else: + return None + + provider_id, provider_separator, provider_model = remainder.partition("/") + if not provider_separator or not provider_model: + return None + + return DecodedGatewayModelId( + provider_id=provider_id, + provider_model=provider_model, + force_thinking_enabled=force_thinking_enabled, + ) diff --git a/src/free_claude_code/core/openai_responses/__init__.py b/src/free_claude_code/core/openai_responses/__init__.py new file mode 100644 index 0000000..3835f8c --- /dev/null +++ b/src/free_claude_code/core/openai_responses/__init__.py @@ -0,0 +1,17 @@ +"""OpenAI Responses protocol adapter.""" + +from .adapter import OpenAIResponsesAdapter +from .errors import ( + openai_error_payload, + openai_error_type_for_failure, + openai_failure_payload, +) +from .models import OpenAIResponsesRequest + +__all__ = [ + "OpenAIResponsesAdapter", + "OpenAIResponsesRequest", + "openai_error_payload", + "openai_error_type_for_failure", + "openai_failure_payload", +] diff --git a/src/free_claude_code/core/openai_responses/adapter.py b/src/free_claude_code/core/openai_responses/adapter.py new file mode 100644 index 0000000..bb0e2ac --- /dev/null +++ b/src/free_claude_code/core/openai_responses/adapter.py @@ -0,0 +1,39 @@ +"""Facade for OpenAI Responses protocol adaptation.""" + +from collections.abc import AsyncIterable, AsyncIterator +from typing import Any, ClassVar + +from .errors import ResponsesConversionError, openai_error_payload +from .events import OPENAI_RESPONSES_SSE_HEADERS +from .input import convert_request_to_anthropic_payload +from .models import OpenAIResponsesRequest +from .stream import ( + PostStartTerminalFailureObserver, + iter_responses_sse_from_anthropic, +) + + +class OpenAIResponsesAdapter: + """Convert between OpenAI Responses and the proxy's Anthropic core path.""" + + ConversionError: ClassVar[type[ResponsesConversionError]] = ResponsesConversionError + sse_headers: ClassVar[dict[str, str]] = OPENAI_RESPONSES_SSE_HEADERS + + def to_anthropic_payload(self, request: OpenAIResponsesRequest) -> dict[str, Any]: + return convert_request_to_anthropic_payload(request) + + def iter_sse_from_anthropic( + self, + chunks: AsyncIterable[Any], + request: OpenAIResponsesRequest, + *, + on_post_start_terminal_failure: PostStartTerminalFailureObserver | None = None, + ) -> AsyncIterator[str]: + return iter_responses_sse_from_anthropic( + chunks, + request, + on_post_start_terminal_failure=on_post_start_terminal_failure, + ) + + def error_payload(self, *, message: str, error_type: str) -> dict[str, Any]: + return openai_error_payload(message=message, error_type=error_type) diff --git a/src/free_claude_code/core/openai_responses/anthropic_sse.py b/src/free_claude_code/core/openai_responses/anthropic_sse.py new file mode 100644 index 0000000..10f4794 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/anthropic_sse.py @@ -0,0 +1,69 @@ +"""Anthropic SSE parsing used by the Responses stream adapter.""" + +import json +import sys +from collections.abc import AsyncIterable, AsyncIterator +from dataclasses import dataclass +from typing import Any + +from free_claude_code.core.trace import close_stream_input + + +@dataclass(slots=True) +class AnthropicSseEvent: + event: str + data: dict[str, Any] + + +async def iter_sse_events( + chunks: AsyncIterable[Any], +) -> AsyncIterator[AnthropicSseEvent]: + buffer = "" + iterator = aiter(chunks) + try: + async for chunk in iterator: + if isinstance(chunk, bytes): + buffer += chunk.decode("utf-8", errors="replace") + else: + buffer += str(chunk) + + while "\n\n" in buffer: + raw, buffer = buffer.split("\n\n", 1) + event = parse_sse_event(raw) + if event is not None: + yield event + + if buffer.strip(): + event = parse_sse_event(buffer) + if event is not None: + yield event + finally: + await close_stream_input( + iterator, + owner="openai_responses.anthropic_sse", + source="core", + preserved_error=sys.exception(), + ) + + +def parse_sse_event(raw: str) -> AnthropicSseEvent | None: + event_type = "" + data_parts: list[str] = [] + for line in raw.splitlines(): + stripped = line.rstrip("\r") + if stripped.startswith("event:"): + event_type = stripped.split(":", 1)[1].strip() + elif stripped.startswith("data:"): + data_parts.append(stripped.split(":", 1)[1].strip()) + if not event_type and not data_parts: + return None + data_text = "\n".join(data_parts) + if data_text == "[DONE]": + return None + try: + parsed = json.loads(data_text) if data_text else {} + except json.JSONDecodeError: + parsed = {"raw": data_text} + if not isinstance(parsed, dict): + parsed = {"value": parsed} + return AnthropicSseEvent(event=event_type, data=parsed) diff --git a/src/free_claude_code/core/openai_responses/errors.py b/src/free_claude_code/core/openai_responses/errors.py new file mode 100644 index 0000000..1e28867 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/errors.py @@ -0,0 +1,67 @@ +"""Errors and error envelopes for OpenAI Responses compatibility.""" + +from typing import Any + +from free_claude_code.core.diagnostics import redact_sensitive_error_text +from free_claude_code.core.failures import ExecutionFailure, FailureKind + +_FAILURE_ERROR_TYPES = { + FailureKind.INVALID_REQUEST: "invalid_request_error", + FailureKind.AUTHENTICATION: "authentication_error", + FailureKind.PERMISSION: "permission_error", + FailureKind.RATE_LIMIT: "rate_limit_error", + FailureKind.OVERLOADED: "overloaded_error", + FailureKind.TIMEOUT: "api_error", + FailureKind.UPSTREAM: "api_error", + FailureKind.UNAVAILABLE: "api_error", +} + + +class ResponsesConversionError(ValueError): + """Raised when a Responses request cannot be converted deterministically.""" + + +def openai_error_type_for_failure( + failure: FailureKind | ExecutionFailure, +) -> str: + """Map neutral failure semantics to an OpenAI-compatible error type.""" + if isinstance(failure, ExecutionFailure): + if failure.kind == FailureKind.PERMISSION and failure.status_code == 402: + return "billing_error" + if failure.kind == FailureKind.INVALID_REQUEST: + if failure.status_code == 404: + return "not_found_error" + if failure.status_code == 413: + return "request_too_large" + if failure.kind == FailureKind.TIMEOUT and failure.status_code == 504: + return "timeout_error" + kind = failure.kind + else: + kind = failure + return _FAILURE_ERROR_TYPES[kind] + + +def openai_error_payload(*, message: str, error_type: str) -> dict[str, Any]: + """Return an OpenAI-compatible error envelope.""" + + return { + "error": { + "message": redact_sensitive_error_text(message), + "type": error_type, + "param": None, + "code": None, + } + } + + +def openai_error_from_failure(failure: ExecutionFailure) -> dict[str, Any]: + """Return the inner OpenAI error object for a canonical failure.""" + return openai_error_payload( + message=failure.message, + error_type=openai_error_type_for_failure(failure), + )["error"] + + +def openai_failure_payload(failure: ExecutionFailure) -> dict[str, Any]: + """Return an OpenAI-compatible envelope for a canonical failure.""" + return {"error": openai_error_from_failure(failure)} diff --git a/src/free_claude_code/core/openai_responses/events.py b/src/free_claude_code/core/openai_responses/events.py new file mode 100644 index 0000000..0cbec42 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/events.py @@ -0,0 +1,17 @@ +"""OpenAI Responses SSE event formatting.""" + +import json +from collections.abc import Mapping +from typing import Any + +OPENAI_RESPONSES_SSE_HEADERS: dict[str, str] = { + "X-Accel-Buffering": "no", + "Cache-Control": "no-cache", + "Connection": "keep-alive", +} + + +def format_response_sse_event(event_type: str, data: Mapping[str, Any]) -> str: + """Format one OpenAI Responses SSE event.""" + + return f"event: {event_type}\ndata: {json.dumps(data)}\n\n" diff --git a/src/free_claude_code/core/openai_responses/ids.py b/src/free_claude_code/core/openai_responses/ids.py new file mode 100644 index 0000000..0ef53f0 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/ids.py @@ -0,0 +1,19 @@ +"""Identifier helpers for OpenAI Responses payloads.""" + +import uuid + + +def new_response_id() -> str: + return f"resp_{uuid.uuid4().hex}" + + +def new_message_item_id() -> str: + return f"msg_{uuid.uuid4().hex}" + + +def new_reasoning_item_id() -> str: + return f"rs_{uuid.uuid4().hex}" + + +def new_call_id() -> str: + return f"call_{uuid.uuid4().hex[:24]}" diff --git a/src/free_claude_code/core/openai_responses/input.py b/src/free_claude_code/core/openai_responses/input.py new file mode 100644 index 0000000..3fc2232 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/input.py @@ -0,0 +1,357 @@ +"""Convert OpenAI Responses requests into Anthropic Messages payloads.""" + +from collections.abc import Mapping +from typing import Any + +from free_claude_code.core.trace import trace_event + +from .errors import ResponsesConversionError +from .models import OpenAIResponsesRequest +from .reasoning import ( + combine_reasoning, + reasoning_text_from_item, + responses_reasoning_to_thinking, +) +from .tools import ( + call_id_from_item, + convert_tool_choice, + convert_tools, + custom_tool_input_to_anthropic, + optional_str, + parse_arguments, + required_str, + responses_tool_name_to_anthropic_name, +) + + +def convert_request_to_anthropic_payload( + request: OpenAIResponsesRequest, +) -> dict[str, Any]: + """Convert an OpenAI Responses request into an Anthropic Messages payload.""" + + system_parts: list[str] = [] + if instructions := request.instructions: + system_parts.append(instructions) + + messages: list[dict[str, Any]] = [] + pending_reasoning: str | None = None + quarantined_function_call_ids: set[str] = set() + for item in _iter_input_items(request.input): + pending_reasoning = _append_input_item( + item, + messages=messages, + system_parts=system_parts, + pending_reasoning=pending_reasoning, + quarantined_function_call_ids=quarantined_function_call_ids, + ) + _append_pending_reasoning(messages, pending_reasoning) + + if not messages: + raise ResponsesConversionError("Responses request input must contain a message") + + payload: dict[str, Any] = { + "model": required_str(request.model, "model"), + "messages": messages, + "stream": True, + } + if system_parts: + payload["system"] = "\n\n".join(system_parts) + if request.temperature is not None: + payload["temperature"] = request.temperature + if request.top_p is not None: + payload["top_p"] = request.top_p + if request.max_output_tokens is not None: + payload["max_tokens"] = request.max_output_tokens + if request.metadata is not None: + payload["metadata"] = request.metadata + + if thinking := responses_reasoning_to_thinking(request.reasoning): + payload["thinking"] = thinking + + raw_tool_choice = request.tool_choice + tools = convert_tools(request.tools) + if tools and raw_tool_choice != "none": + payload["tools"] = tools + tool_choice = convert_tool_choice(raw_tool_choice) + if tool_choice is not None: + payload["tool_choice"] = tool_choice + + return payload + + +def _append_input_item( + item: Any, + *, + messages: list[dict[str, Any]], + system_parts: list[str], + pending_reasoning: str | None, + quarantined_function_call_ids: set[str], +) -> str | None: + if isinstance(item, str): + _append_pending_reasoning(messages, pending_reasoning) + messages.append({"role": "user", "content": item}) + return None + if not isinstance(item, dict): + raise ResponsesConversionError( + f"Unsupported Responses input item: {type(item).__name__}" + ) + + item_type = item.get("type") + if item_type in (None, "message") or "role" in item: + role = required_str(item.get("role", "user"), "input.role") + if role == "assistant": + _append_message_item( + role, + item.get("content", ""), + messages, + system_parts, + reasoning_content=pending_reasoning, + ) + return None + _append_pending_reasoning(messages, pending_reasoning) + _append_message_item(role, item.get("content", ""), messages, system_parts) + return None + if item_type in {"function_call", "custom_tool_call"}: + namespace = optional_str(item.get("namespace")) + field_name = f"{item_type}.name" + name = required_str(item.get("name"), field_name) + call_id = call_id_from_item(item) + if item_type == "custom_tool_call": + tool_input = custom_tool_input_to_anthropic(item.get("input")) + else: + try: + tool_input = parse_arguments(item.get("arguments")) + except ResponsesConversionError as exc: + quarantined_function_call_ids.add(call_id) + _trace_quarantined_function_call(call_id, exc) + return pending_reasoning + tool_use = { + "type": "tool_use", + "id": call_id, + "name": responses_tool_name_to_anthropic_name(name, namespace=namespace), + "input": tool_input, + } + _append_tool_use_message( + messages, + tool_use, + reasoning_content=pending_reasoning, + ) + return None + if item_type in {"function_call_output", "custom_tool_call_output"}: + call_id = call_id_from_item(item) + if ( + item_type == "function_call_output" + and call_id in quarantined_function_call_ids + ): + return pending_reasoning + _append_pending_reasoning_before_tool_output(messages, pending_reasoning) + _append_tool_result_message( + messages, + { + "type": "tool_result", + "tool_use_id": call_id, + "content": item.get("output", ""), + }, + ) + return None + if item_type == "reasoning": + return combine_reasoning(pending_reasoning, reasoning_text_from_item(item)) + if item_type in {"input_text", "output_text", "text"}: + _append_pending_reasoning(messages, pending_reasoning) + messages.append({"role": "user", "content": _text_from_part(item)}) + return None + + raise ResponsesConversionError( + f"Unsupported Responses input item type: {item_type!r}" + ) + + +def _trace_quarantined_function_call( + call_id: str, exc: ResponsesConversionError +) -> None: + trace_event( + stage="responses", + event="responses.input.function_call_quarantined", + source="openai_responses", + call_id=call_id, + error_type=type(exc).__name__, + ) + + +def _append_message_item( + role: str, + content: Any, + messages: list[dict[str, Any]], + system_parts: list[str], + *, + reasoning_content: str | None = None, +) -> None: + normalized_role = "system" if role == "developer" else role + if normalized_role == "system": + text = _content_as_text(content) + if text: + system_parts.append(text) + return + if normalized_role not in {"user", "assistant"}: + raise ResponsesConversionError(f"Unsupported Responses message role: {role!r}") + message = { + "role": normalized_role, + "content": _convert_message_content(content), + } + if normalized_role == "assistant" and reasoning_content is not None: + message["reasoning_content"] = reasoning_content + messages.append(message) + + +def _append_pending_reasoning( + messages: list[dict[str, Any]], pending_reasoning: str | None +) -> None: + if pending_reasoning is not None: + messages.append( + { + "role": "assistant", + "content": "", + "reasoning_content": pending_reasoning, + } + ) + + +def _append_pending_reasoning_before_tool_output( + messages: list[dict[str, Any]], pending_reasoning: str | None +) -> None: + if pending_reasoning is None: + return + message = _last_assistant_tool_use_message(messages) + if message is None: + _append_pending_reasoning(messages, pending_reasoning) + return + _merge_message_reasoning(message, pending_reasoning) + + +def _append_tool_use_message( + messages: list[dict[str, Any]], + tool_use: dict[str, Any], + *, + reasoning_content: str | None, +) -> None: + message = _last_assistant_tool_use_message(messages) + if message is None: + message = {"role": "assistant", "content": []} + messages.append(message) + if reasoning_content is not None: + _merge_message_reasoning(message, reasoning_content) + content = message["content"] + if isinstance(content, list): + content.append(tool_use) + + +def _append_tool_result_message( + messages: list[dict[str, Any]], + tool_result: dict[str, Any], +) -> None: + message = _last_user_tool_result_message(messages) + if message is None: + message = {"role": "user", "content": []} + messages.append(message) + content = message["content"] + if isinstance(content, list): + content.append(tool_result) + + +def _last_assistant_tool_use_message( + messages: list[dict[str, Any]], +) -> dict[str, Any] | None: + if not messages: + return None + message = messages[-1] + if message.get("role") != "assistant": + return None + content = message.get("content") + if not isinstance(content, list) or not content: + return None + if all( + isinstance(block, dict) and block.get("type") == "tool_use" for block in content + ): + return message + return None + + +def _last_user_tool_result_message( + messages: list[dict[str, Any]], +) -> dict[str, Any] | None: + if not messages: + return None + message = messages[-1] + if message.get("role") != "user": + return None + content = message.get("content") + if not isinstance(content, list) or not content: + return None + if all( + isinstance(block, dict) and block.get("type") == "tool_result" + for block in content + ): + return message + return None + + +def _merge_message_reasoning(message: dict[str, Any], reasoning: str) -> None: + existing = message.get("reasoning_content") + existing_reasoning = existing if isinstance(existing, str) else None + message["reasoning_content"] = combine_reasoning(existing_reasoning, reasoning) + + +def _iter_input_items(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + return [value] + + +def _convert_message_content(content: Any) -> str | list[dict[str, Any]]: + if isinstance(content, str): + return content + if isinstance(content, list): + blocks: list[dict[str, Any]] = [] + for part in content: + if isinstance(part, str): + blocks.append({"type": "text", "text": part}) + continue + if not isinstance(part, dict): + raise ResponsesConversionError( + f"Unsupported Responses content part: {type(part).__name__}" + ) + part_type = part.get("type") + if part_type in {"input_text", "output_text", "text"} or "text" in part: + blocks.append({"type": "text", "text": _text_from_part(part)}) + continue + if part_type == "refusal": + blocks.append({"type": "text", "text": str(part.get("refusal", ""))}) + continue + raise ResponsesConversionError( + f"Unsupported Responses content part type: {part_type!r}" + ) + return blocks + if isinstance(content, dict): + return [{"type": "text", "text": _text_from_part(content)}] + raise ResponsesConversionError( + f"Unsupported Responses message content: {type(content).__name__}" + ) + + +def _content_as_text(content: Any) -> str: + converted = _convert_message_content(content) + if isinstance(converted, str): + return converted + return "\n".join(str(block.get("text", "")) for block in converted) + + +def _text_from_part(part: Mapping[str, Any]) -> str: + if text := optional_str(part.get("text")): + return text + if text := optional_str(part.get("input_text")): + return text + if text := optional_str(part.get("output_text")): + return text + return "" diff --git a/src/free_claude_code/core/openai_responses/items.py b/src/free_claude_code/core/openai_responses/items.py new file mode 100644 index 0000000..e3f9bd5 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/items.py @@ -0,0 +1,35 @@ +"""Responses object and output item builders.""" + +from typing import Any + + +def message_item(item_id: str, text: str, status: str) -> dict[str, Any]: + return { + "id": item_id, + "type": "message", + "status": status, + "role": "assistant", + "content": [{"type": "output_text", "text": text, "annotations": []}], + } + + +def reasoning_item(item_id: str, text: str, status: str) -> dict[str, Any]: + return { + "id": item_id, + "type": "reasoning", + "status": status, + "summary": [], + "content": [{"type": "reasoning_text", "text": text}], + } + + +def encrypted_reasoning_item( + item_id: str, encrypted_content: str, status: str +) -> dict[str, Any]: + return { + "id": item_id, + "type": "reasoning", + "status": status, + "summary": [], + "encrypted_content": encrypted_content, + } diff --git a/src/free_claude_code/core/openai_responses/models.py b/src/free_claude_code/core/openai_responses/models.py new file mode 100644 index 0000000..ec80b44 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/models.py @@ -0,0 +1,26 @@ +"""Pydantic models for OpenAI Responses-compatible ingress.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class OpenAIResponsesRequest(BaseModel): + """Permissive subset of the OpenAI Responses API request shape.""" + + model_config = ConfigDict(extra="allow") + + model: str + input: Any = None + instructions: str | None = None + tools: list[dict[str, Any]] | None = None + tool_choice: Any = None + parallel_tool_calls: bool | None = None + stream: bool | None = True + temperature: float | None = None + top_p: float | None = None + max_output_tokens: int | None = None + metadata: dict[str, Any] | None = None + reasoning: dict[str, Any] | None = None + previous_response_id: str | None = None + store: bool | None = None diff --git a/src/free_claude_code/core/openai_responses/reasoning.py b/src/free_claude_code/core/openai_responses/reasoning.py new file mode 100644 index 0000000..12a732f --- /dev/null +++ b/src/free_claude_code/core/openai_responses/reasoning.py @@ -0,0 +1,54 @@ +"""Reasoning and thinking conversion helpers for OpenAI Responses.""" + +from collections.abc import Mapping +from typing import Any + +from .tools import optional_str + + +def reasoning_text_from_item(item: Mapping[str, Any]) -> str | None: + content_parts = _text_parts_from_items( + item.get("content"), item_type="reasoning_text" + ) + if content_parts: + return "\n".join(content_parts) + summary_parts = _text_parts_from_items( + item.get("summary"), item_type="summary_text" + ) + if summary_parts: + return "\n".join(summary_parts) + return None + + +def combine_reasoning(existing: str | None, addition: str | None) -> str | None: + if addition is None: + return existing + if existing is None: + return addition + if existing == "": + return addition + if addition == "": + return existing + return f"{existing}\n{addition}" + + +def responses_reasoning_to_thinking(value: Any) -> dict[str, Any] | None: + if not isinstance(value, Mapping): + return None + if value.get("effort") == "none": + return {"type": "disabled", "enabled": False} + if any(item is not None for item in value.values()): + return {"type": "enabled", "enabled": True} + return None + + +def _text_parts_from_items(value: Any, *, item_type: str) -> list[str]: + if not isinstance(value, list): + return [] + parts: list[str] = [] + for item in value: + if isinstance(item, dict) and item.get("type") == item_type: + text = optional_str(item.get("text")) + if text is not None: + parts.append(text) + return parts diff --git a/src/free_claude_code/core/openai_responses/stream.py b/src/free_claude_code/core/openai_responses/stream.py new file mode 100644 index 0000000..833c31e --- /dev/null +++ b/src/free_claude_code/core/openai_responses/stream.py @@ -0,0 +1,92 @@ +"""Translate Anthropic SSE streams into OpenAI Responses SSE streams.""" + +import asyncio +import sys +from collections.abc import AsyncIterable, AsyncIterator, Callable +from typing import Any + +from free_claude_code.core.diagnostics import safe_exception_message +from free_claude_code.core.failures import ExecutionFailure, find_execution_failure +from free_claude_code.core.trace import close_stream_input + +from .anthropic_sse import iter_sse_events +from .models import OpenAIResponsesRequest +from .streaming import ResponsesStreamAssembler + +PostStartTerminalFailureObserver = Callable[[BaseException], None] + + +async def iter_responses_sse_from_anthropic( + chunks: AsyncIterable[Any], + request: OpenAIResponsesRequest, + *, + on_post_start_terminal_failure: PostStartTerminalFailureObserver | None = None, +) -> AsyncIterator[str]: + """Yield Responses SSE events translated from an Anthropic SSE stream.""" + assembler = ResponsesStreamAssembler(request) + emitted_any_chunk = False + events = iter_sse_events(chunks) + try: + async for event in events: + for chunk in assembler.process_anthropic_event(event): + yield chunk + emitted_any_chunk = True + if assembler.terminal: + return + for chunk in assembler.finish_if_needed(): + yield chunk + emitted_any_chunk = True + except GeneratorExit: + raise + except asyncio.CancelledError: + raise + except ExecutionFailure as exc: + if not emitted_any_chunk: + raise + _observe_post_start_terminal_failure(on_post_start_terminal_failure, exc) + for chunk in assembler.fail_execution(exc): + yield chunk + except BaseExceptionGroup as exc: + if not emitted_any_chunk: + raise + failure = find_execution_failure(exc) + if failure is not None: + _observe_post_start_terminal_failure( + on_post_start_terminal_failure, failure + ) + for chunk in assembler.fail_execution(failure): + yield chunk + else: + _observe_post_start_terminal_failure(on_post_start_terminal_failure, exc) + for chunk in assembler.fail_response(_unexpected_error_data(exc)): + yield chunk + except Exception as exc: + if not emitted_any_chunk: + raise + _observe_post_start_terminal_failure(on_post_start_terminal_failure, exc) + for chunk in assembler.fail_response(_unexpected_error_data(exc)): + yield chunk + finally: + await close_stream_input( + events, + owner="openai_responses.stream", + source="core", + preserved_error=sys.exception(), + ) + + +def _observe_post_start_terminal_failure( + observer: PostStartTerminalFailureObserver | None, + exc: BaseException, +) -> None: + if observer is not None: + observer(exc) + + +def _unexpected_error_data(exc: BaseException) -> dict[str, dict[str, str]]: + return { + "error": { + "type": "api_error", + "message": safe_exception_message(exc), + } + } diff --git a/src/free_claude_code/core/openai_responses/streaming/__init__.py b/src/free_claude_code/core/openai_responses/streaming/__init__.py new file mode 100644 index 0000000..fc3056a --- /dev/null +++ b/src/free_claude_code/core/openai_responses/streaming/__init__.py @@ -0,0 +1,5 @@ +"""OpenAI Responses streaming assembly internals.""" + +from .assembler import ResponsesStreamAssembler + +__all__ = ["ResponsesStreamAssembler"] diff --git a/src/free_claude_code/core/openai_responses/streaming/assembler.py b/src/free_claude_code/core/openai_responses/streaming/assembler.py new file mode 100644 index 0000000..1511e3f --- /dev/null +++ b/src/free_claude_code/core/openai_responses/streaming/assembler.py @@ -0,0 +1,354 @@ +"""Anthropic SSE to OpenAI Responses stream assembly.""" + +import json +import time +import uuid +from collections.abc import Mapping +from typing import Any + +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.core.trace import trace_event + +from ..anthropic_sse import AnthropicSseEvent +from ..errors import ResponsesConversionError, openai_error_from_failure +from ..ids import ( + new_call_id, + new_message_item_id, + new_reasoning_item_id, + new_response_id, +) +from ..models import OpenAIResponsesRequest +from ..tools import responses_tool_identity_from_anthropic_name +from . import event_builders as events +from .blocks import ReasoningBlockState, TextBlockState, ToolBlockState +from .completion import ResponseBlockCompleter, reasoning_output_item, tool_item +from .error_mapping import ( + openai_error_from_anthropic_error, + replay_unsafe_function_call_error, +) +from .ledger import ResponsesOutputLedger + + +class ResponsesStreamAssembler: + """Assemble Responses SSE events from indexed Anthropic content blocks.""" + + def __init__(self, request: OpenAIResponsesRequest) -> None: + self._request = request + self._response_id = new_response_id() + self._created_at = int(time.time()) + self._ledger = ResponsesOutputLedger() + self._completer = ResponseBlockCompleter( + self._ledger, + on_invalid_function_call=self._fail_invalid_function_call, + ) + self._started = False + self._provisional_error: dict[str, Any] | None = None + self.terminal = False + self.final_response: dict[str, Any] | None = None + + def process_anthropic_event(self, event: AnthropicSseEvent) -> list[str]: + if self.terminal: + return [] + + chunks = self._ensure_started() + if event.event == "content_block_start": + chunks.extend(self._handle_content_block_start(event.data)) + elif event.event == "content_block_delta": + chunks.extend(self._handle_content_block_delta(event.data)) + elif event.event == "content_block_stop": + chunks.extend(self._handle_content_block_stop(event.data)) + elif event.event == "message_delta": + self._ledger.record_usage_delta(event.data) + elif event.event == "message_stop": + chunks.extend(self.complete_response()) + elif event.event == "error": + chunks.extend(self.fail_response(event.data)) + return chunks + + def finish_if_needed(self) -> list[str]: + if self.terminal: + return [] + chunks = self._ensure_started() + chunks.extend(self.complete_response()) + return chunks + + def response_payload( + self, *, status: str, error: dict[str, Any] | None = None + ) -> dict[str, Any]: + return { + "id": self._response_id, + "object": "response", + "created_at": self._created_at, + "status": status, + "model": self._request.model, + "output": self._ledger.output(), + "parallel_tool_calls": ( + True + if self._request.parallel_tool_calls is None + else self._request.parallel_tool_calls + ), + "tool_choice": ( + "auto" + if self._request.tool_choice is None + else self._request.tool_choice + ), + "temperature": self._request.temperature, + "top_p": self._request.top_p, + "max_output_tokens": self._request.max_output_tokens, + "usage": self._ledger.usage(), + "error": error, + } + + def complete_response(self) -> list[str]: + chunks = self._flush_active_blocks() + if self.terminal: + return chunks + if self._provisional_error is not None: + chunks.extend(self._finish_failed_response(self._provisional_error)) + return chunks + self.final_response = self.response_payload(status="completed") + chunks.append(events.response_completed(self.final_response)) + self.terminal = True + return chunks + + def fail_response(self, data: Mapping[str, Any]) -> list[str]: + chunks = self._flush_active_blocks() + if self.terminal: + return chunks + error = openai_error_from_anthropic_error(data) + chunks.extend(self._finish_failed_response(error)) + return chunks + + def fail_execution(self, failure: ExecutionFailure) -> list[str]: + """Finish the current response with a canonical execution failure.""" + chunks = self._flush_active_blocks() + if self.terminal: + return chunks + chunks.extend(self._finish_failed_response(openai_error_from_failure(failure))) + return chunks + + def _finish_failed_response(self, error: dict[str, Any]) -> list[str]: + self._provisional_error = None + self.final_response = self.response_payload(status="failed", error=error) + self.terminal = True + return [events.response_failed(self.final_response)] + + def _ensure_started(self) -> list[str]: + if self._started: + return [] + self._started = True + return [events.response_created(self.response_payload(status="in_progress"))] + + def _handle_content_block_start(self, data: Mapping[str, Any]) -> list[str]: + block = data.get("content_block") + if not isinstance(block, dict): + return [] + block_type = block.get("type") + index = _event_index(data) + if block_type == "text": + index = self._ledger.safe_text_index(index) + chunks, state = self._start_text_block(index) + if state is None: + return chunks + if text := _string_value(block.get("text")): + chunks.extend(self._emit_text_delta(state, text)) + return chunks + if block_type == "thinking": + if index is None: + return [] + chunks, state = self._start_reasoning_block(index) + if state is None: + return chunks + if text := _string_value(block.get("thinking")): + chunks.extend(self._emit_reasoning_delta(state, text)) + return chunks + if block_type == "redacted_thinking": + if index is None: + return [] + chunks, _state = self._start_reasoning_block( + index, encrypted_content=_string_value(block.get("data")) + ) + return chunks + if block_type == "tool_use": + if index is None: + return [] + return self._start_tool_block(index, block) + return [] + + def _handle_content_block_delta(self, data: Mapping[str, Any]) -> list[str]: + delta = data.get("delta") + if not isinstance(delta, dict): + return [] + delta_type = delta.get("type") + index = _event_index(data) + if delta_type == "text_delta": + index = self._ledger.safe_text_index(index) + state = self._ledger.active_block(index) + chunks: list[str] = [] + if not isinstance(state, TextBlockState): + chunks, state = self._start_text_block(index) + if state is None: + return chunks + chunks.extend( + self._emit_text_delta(state, _string_value(delta.get("text"))) + ) + return chunks + if delta_type == "thinking_delta": + if index is None: + return [] + state = self._ledger.active_block(index) + chunks = [] + if not isinstance(state, ReasoningBlockState): + chunks, state = self._start_reasoning_block(index) + if state is None: + return chunks + chunks.extend( + self._emit_reasoning_delta(state, _string_value(delta.get("thinking"))) + ) + return chunks + if delta_type == "input_json_delta": + state = self._ledger.active_block(index) if index is not None else None + if isinstance(state, ToolBlockState): + state.argument_parts.append(_string_value(delta.get("partial_json"))) + return [] + + def _handle_content_block_stop(self, data: Mapping[str, Any]) -> list[str]: + index = _event_index(data) + if index is None: + return [] + state = self._ledger.pop_active_block(index) + if state is None: + return [] + return self._completer.complete_block(state) + + def _start_text_block(self, index: int) -> tuple[list[str], TextBlockState | None]: + chunks = self._complete_existing_block(index) + if self.terminal: + return chunks, None + output_index = self._ledger.reserve_output_slot() + state = TextBlockState( + index=index, + output_index=output_index, + item_id=new_message_item_id(), + ) + self._ledger.set_active_block(state) + item = { + "id": state.item_id, + "type": "message", + "status": "in_progress", + "role": "assistant", + "content": [], + } + chunks.extend( + [ + events.output_item_added(output_index, item), + events.content_part_added(state.item_id, output_index), + ] + ) + return chunks, state + + def _start_reasoning_block( + self, index: int, *, encrypted_content: str | None = None + ) -> tuple[list[str], ReasoningBlockState | None]: + chunks = self._complete_existing_block(index) + if self.terminal: + return chunks, None + output_index = self._ledger.reserve_output_slot() + state = ReasoningBlockState( + index=index, + output_index=output_index, + item_id=new_reasoning_item_id(), + encrypted_content=encrypted_content, + ) + self._ledger.set_active_block(state) + chunks.append( + events.output_item_added( + output_index, + reasoning_output_item(state, status="in_progress"), + ) + ) + return chunks, state + + def _start_tool_block(self, index: int, block: Mapping[str, Any]) -> list[str]: + chunks = self._complete_existing_block(index) + if self.terminal: + return chunks + identity = responses_tool_identity_from_anthropic_name( + self._request.tools, _string_value(block.get("name")) + ) + state = ToolBlockState( + index=index, + output_index=self._ledger.reserve_output_slot(), + item_id=f"{'ctc' if identity.kind == 'custom' else 'fc'}_" + f"{uuid.uuid4().hex[:24]}", + call_id=_string_value(block.get("id")) or new_call_id(), + kind=identity.kind, + name=identity.name, + namespace=identity.namespace, + ) + initial_input = block.get("input") + if (identity.kind == "custom" and initial_input not in (None, {}, "")) or ( + isinstance(initial_input, dict) and initial_input + ): + state.argument_parts.append(json.dumps(initial_input)) + self._ledger.set_active_block(state) + chunks.append( + events.output_item_added( + state.output_index, + tool_item(state, status="in_progress"), + ) + ) + return chunks + + def _emit_text_delta(self, state: TextBlockState, text: str) -> list[str]: + if not text: + return [] + state.text_parts.append(text) + return [events.output_text_delta(state.item_id, state.output_index, text)] + + def _emit_reasoning_delta(self, state: ReasoningBlockState, text: str) -> list[str]: + if not text: + return [] + state.text_parts.append(text) + return [events.reasoning_text_delta(state.item_id, state.output_index, text)] + + def _complete_existing_block(self, index: int) -> list[str]: + existing = self._ledger.pop_active_block(index) + if existing is None: + return [] + return self._completer.complete_block(existing) + + def _flush_active_blocks(self) -> list[str]: + chunks: list[str] = [] + for state in self._ledger.pop_active_blocks_by_output_order(): + if self.terminal: + break + chunks.extend(self._completer.complete_block(state)) + return chunks + + def _fail_invalid_function_call( + self, state: ToolBlockState, exc: ResponsesConversionError + ) -> list[str]: + trace_event( + stage="responses", + event="responses.output.function_call_invalid_arguments", + source="openai_responses", + call_id=state.call_id, + tool_name=state.name, + error_type=type(exc).__name__, + ) + error = replay_unsafe_function_call_error() + if self._provisional_error is None: + self._provisional_error = error + return [] + + +def _event_index(data: Mapping[str, Any]) -> int | None: + value = data.get("index") + return value if isinstance(value, int) else None + + +def _string_value(value: Any) -> str: + if value is None: + return "" + return value if isinstance(value, str) else str(value) diff --git a/src/free_claude_code/core/openai_responses/streaming/blocks.py b/src/free_claude_code/core/openai_responses/streaming/blocks.py new file mode 100644 index 0000000..581f7b3 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/streaming/blocks.py @@ -0,0 +1,36 @@ +"""Block state for OpenAI Responses streaming assembly.""" + +from dataclasses import dataclass, field +from typing import Literal + + +@dataclass(slots=True) +class TextBlockState: + index: int + output_index: int + item_id: str + text_parts: list[str] = field(default_factory=list) + + +@dataclass(slots=True) +class ReasoningBlockState: + index: int + output_index: int + item_id: str + text_parts: list[str] = field(default_factory=list) + encrypted_content: str | None = None + + +@dataclass(slots=True) +class ToolBlockState: + index: int + output_index: int + item_id: str + call_id: str + kind: Literal["function", "custom"] + name: str + namespace: str | None = None + argument_parts: list[str] = field(default_factory=list) + + +BlockState = TextBlockState | ReasoningBlockState | ToolBlockState diff --git a/src/free_claude_code/core/openai_responses/streaming/completion.py b/src/free_claude_code/core/openai_responses/streaming/completion.py new file mode 100644 index 0000000..f118fa9 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/streaming/completion.py @@ -0,0 +1,154 @@ +"""Block finalization for OpenAI Responses streams.""" + +from collections.abc import Callable + +from ..errors import ResponsesConversionError +from ..items import encrypted_reasoning_item, message_item, reasoning_item +from ..tools import ( + custom_tool_input_text_from_arguments, + normalized_function_call_arguments, +) +from . import event_builders as events +from .blocks import BlockState, ReasoningBlockState, TextBlockState, ToolBlockState +from .ledger import ResponsesOutputLedger + +InvalidFunctionCallHandler = Callable[ + [ToolBlockState, ResponsesConversionError], list[str] +] + + +class ResponseBlockCompleter: + """Finalize active Responses output blocks.""" + + def __init__( + self, + ledger: ResponsesOutputLedger, + *, + on_invalid_function_call: InvalidFunctionCallHandler, + ) -> None: + self._ledger = ledger + self._on_invalid_function_call = on_invalid_function_call + + def complete_block(self, state: BlockState) -> list[str]: + if isinstance(state, TextBlockState): + return self._complete_text_block(state) + if isinstance(state, ReasoningBlockState): + return self._complete_reasoning_block(state) + return self._complete_tool_block(state) + + def _complete_text_block(self, state: TextBlockState) -> list[str]: + text = "".join(state.text_parts) + item = message_item(state.item_id, text, "completed") + self._ledger.commit_output(state.output_index, item) + return [ + events.output_text_done(state.item_id, state.output_index, text), + events.content_part_done(state.item_id, state.output_index, text), + events.output_item_done(state.output_index, item), + ] + + def _complete_reasoning_block(self, state: ReasoningBlockState) -> list[str]: + item = _reasoning_output_item(state, status="completed") + self._ledger.commit_output(state.output_index, item) + chunks: list[str] = [] + text = "".join(state.text_parts) + if text: + self._ledger.add_reasoning_text(text) + chunks.append( + events.reasoning_text_done(state.item_id, state.output_index, text) + ) + chunks.append(events.output_item_done(state.output_index, item)) + return chunks + + def _complete_tool_block(self, state: ToolBlockState) -> list[str]: + if state.kind == "custom": + return self._complete_custom_tool_block(state) + raw_arguments = "".join(state.argument_parts) or "{}" + try: + arguments = normalized_function_call_arguments(raw_arguments) + except ResponsesConversionError as exc: + return self._on_invalid_function_call(state, exc) + item = tool_item(state, status="completed", arguments=arguments) + self._ledger.commit_output(state.output_index, item) + chunks: list[str] = [] + if arguments: + chunks.append( + events.function_call_arguments_delta( + state.item_id, state.output_index, arguments + ) + ) + chunks.extend( + [ + events.function_call_arguments_done( + state.item_id, state.output_index, arguments + ), + events.output_item_done(state.output_index, item), + ] + ) + return chunks + + def _complete_custom_tool_block(self, state: ToolBlockState) -> list[str]: + input_text = custom_tool_input_text_from_arguments( + "".join(state.argument_parts) + ) + item = tool_item(state, status="completed", input_text=input_text) + self._ledger.commit_output(state.output_index, item) + chunks: list[str] = [] + if input_text: + chunks.append( + events.custom_tool_call_input_delta( + state.item_id, state.output_index, input_text + ) + ) + chunks.extend( + [ + events.custom_tool_call_input_done( + state.item_id, state.output_index, input_text + ), + events.output_item_done(state.output_index, item), + ] + ) + return chunks + + +def tool_item( + state: ToolBlockState, + *, + status: str, + arguments: str = "", + input_text: str = "", +) -> dict[str, object]: + if state.kind == "custom": + item: dict[str, object] = { + "id": state.item_id, + "type": "custom_tool_call", + "status": status, + "call_id": state.call_id, + "name": state.name, + "input": input_text, + } + else: + item = { + "id": state.item_id, + "type": "function_call", + "status": status, + "call_id": state.call_id, + "name": state.name, + "arguments": arguments, + } + if state.namespace: + item["namespace"] = state.namespace + return item + + +def reasoning_output_item( + state: ReasoningBlockState, *, status: str +) -> dict[str, object]: + if state.encrypted_content is not None: + return encrypted_reasoning_item(state.item_id, state.encrypted_content, status) + return reasoning_item(state.item_id, "".join(state.text_parts), status) + + +def _reasoning_output_item( + state: ReasoningBlockState, *, status: str +) -> dict[str, object]: + return reasoning_output_item(state, status=status) diff --git a/src/free_claude_code/core/openai_responses/streaming/error_mapping.py b/src/free_claude_code/core/openai_responses/streaming/error_mapping.py new file mode 100644 index 0000000..ad5878b --- /dev/null +++ b/src/free_claude_code/core/openai_responses/streaming/error_mapping.py @@ -0,0 +1,28 @@ +"""Responses stream error mapping.""" + +from collections.abc import Mapping +from typing import Any + + +def openai_error_from_anthropic_error(data: Mapping[str, Any]) -> dict[str, Any]: + error = data.get("error") + if not isinstance(error, dict): + error = {"type": "api_error", "message": str(data)} + return { + "message": str(error.get("message", "")), + "type": str(error.get("type", "api_error")), + "param": None, + "code": None, + } + + +def replay_unsafe_function_call_error() -> dict[str, Any]: + return { + "message": ( + "Upstream function_call arguments were not a valid JSON object; " + "refusing to emit replay-unsafe Responses output." + ), + "type": "api_error", + "param": None, + "code": None, + } diff --git a/src/free_claude_code/core/openai_responses/streaming/event_builders.py b/src/free_claude_code/core/openai_responses/streaming/event_builders.py new file mode 100644 index 0000000..bad9122 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/streaming/event_builders.py @@ -0,0 +1,182 @@ +"""OpenAI Responses SSE event builders.""" + +from typing import Any + +from ..events import format_response_sse_event + + +def response_created(response: dict[str, Any]) -> str: + return format_response_sse_event( + "response.created", + {"type": "response.created", "response": response}, + ) + + +def response_completed(response: dict[str, Any]) -> str: + return format_response_sse_event( + "response.completed", + {"type": "response.completed", "response": response}, + ) + + +def response_failed(response: dict[str, Any]) -> str: + return format_response_sse_event( + "response.failed", + {"type": "response.failed", "response": response}, + ) + + +def output_item_added(output_index: int, item: dict[str, Any]) -> str: + return format_response_sse_event( + "response.output_item.added", + { + "type": "response.output_item.added", + "output_index": output_index, + "item": item, + }, + ) + + +def output_item_done(output_index: int, item: dict[str, Any]) -> str: + return format_response_sse_event( + "response.output_item.done", + { + "type": "response.output_item.done", + "output_index": output_index, + "item": item, + }, + ) + + +def content_part_added(item_id: str, output_index: int) -> str: + return format_response_sse_event( + "response.content_part.added", + { + "type": "response.content_part.added", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "part": {"type": "output_text", "text": "", "annotations": []}, + }, + ) + + +def output_text_delta(item_id: str, output_index: int, text: str) -> str: + return format_response_sse_event( + "response.output_text.delta", + { + "type": "response.output_text.delta", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "delta": text, + }, + ) + + +def output_text_done(item_id: str, output_index: int, text: str) -> str: + return format_response_sse_event( + "response.output_text.done", + { + "type": "response.output_text.done", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "text": text, + }, + ) + + +def content_part_done(item_id: str, output_index: int, text: str) -> str: + return format_response_sse_event( + "response.content_part.done", + { + "type": "response.content_part.done", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "part": {"type": "output_text", "text": text, "annotations": []}, + }, + ) + + +def reasoning_text_delta(item_id: str, output_index: int, text: str) -> str: + return format_response_sse_event( + "response.reasoning_text.delta", + { + "type": "response.reasoning_text.delta", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "delta": text, + }, + ) + + +def reasoning_text_done(item_id: str, output_index: int, text: str) -> str: + return format_response_sse_event( + "response.reasoning_text.done", + { + "type": "response.reasoning_text.done", + "item_id": item_id, + "output_index": output_index, + "content_index": 0, + "text": text, + }, + ) + + +def function_call_arguments_delta( + item_id: str, output_index: int, arguments: str +) -> str: + return format_response_sse_event( + "response.function_call_arguments.delta", + { + "type": "response.function_call_arguments.delta", + "item_id": item_id, + "output_index": output_index, + "delta": arguments, + }, + ) + + +def function_call_arguments_done( + item_id: str, output_index: int, arguments: str +) -> str: + return format_response_sse_event( + "response.function_call_arguments.done", + { + "type": "response.function_call_arguments.done", + "item_id": item_id, + "output_index": output_index, + "arguments": arguments, + }, + ) + + +def custom_tool_call_input_delta( + item_id: str, output_index: int, input_text: str +) -> str: + return format_response_sse_event( + "response.custom_tool_call_input.delta", + { + "type": "response.custom_tool_call_input.delta", + "item_id": item_id, + "output_index": output_index, + "delta": input_text, + }, + ) + + +def custom_tool_call_input_done( + item_id: str, output_index: int, input_text: str +) -> str: + return format_response_sse_event( + "response.custom_tool_call_input.done", + { + "type": "response.custom_tool_call_input.done", + "item_id": item_id, + "output_index": output_index, + "input": input_text, + }, + ) diff --git a/src/free_claude_code/core/openai_responses/streaming/ledger.py b/src/free_claude_code/core/openai_responses/streaming/ledger.py new file mode 100644 index 0000000..8791bd8 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/streaming/ledger.py @@ -0,0 +1,84 @@ +"""Output ledger for OpenAI Responses streaming assembly.""" + +from collections.abc import Mapping +from typing import Any + +from ..usage import estimate_text_tokens +from .blocks import BlockState + + +class ResponsesOutputLedger: + """Track active blocks, reserved output slots, and accumulated usage.""" + + def __init__(self) -> None: + self._output_slots: list[dict[str, Any] | None] = [] + self._active_blocks: dict[int, BlockState] = {} + self._fallback_text_index = -1 + self._input_tokens: int | None = None + self._output_tokens: int | None = None + self._reasoning_tokens_estimate = 0 + + def active_block(self, index: int) -> BlockState | None: + return self._active_blocks.get(index) + + def set_active_block(self, state: BlockState) -> None: + self._active_blocks[state.index] = state + + def pop_active_block(self, index: int) -> BlockState | None: + return self._active_blocks.pop(index, None) + + def pop_active_blocks_by_output_order(self) -> list[BlockState]: + states = sorted( + self._active_blocks.values(), key=lambda state: state.output_index + ) + self._active_blocks.clear() + return states + + def reserve_output_slot(self) -> int: + output_index = len(self._output_slots) + self._output_slots.append(None) + return output_index + + def commit_output(self, output_index: int, item: dict[str, Any]) -> None: + while output_index >= len(self._output_slots): + self._output_slots.append(None) + self._output_slots[output_index] = item + + def output(self) -> list[dict[str, Any]]: + return [item for item in self._output_slots if item is not None] + + def record_usage_delta(self, data: Mapping[str, Any]) -> None: + usage = data.get("usage") + if not isinstance(usage, dict): + return + if isinstance(usage.get("input_tokens"), int): + self._input_tokens = usage["input_tokens"] + if isinstance(usage.get("output_tokens"), int): + self._output_tokens = usage["output_tokens"] + + def add_reasoning_text(self, text: str) -> None: + self._reasoning_tokens_estimate += estimate_text_tokens(text) + + def usage(self) -> dict[str, Any] | None: + if self._input_tokens is None and self._output_tokens is None: + return None + input_tokens = self._input_tokens or 0 + output_tokens = self._output_tokens or 0 + usage: dict[str, Any] = { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens, + } + capped_reasoning_tokens = min(self._reasoning_tokens_estimate, output_tokens) + if capped_reasoning_tokens: + usage["output_tokens_details"] = { + "reasoning_tokens": capped_reasoning_tokens + } + return usage + + def safe_text_index(self, index: int | None) -> int: + if index is not None: + return index + value = self._fallback_text_index + self._fallback_text_index -= 1 + return value diff --git a/src/free_claude_code/core/openai_responses/tools.py b/src/free_claude_code/core/openai_responses/tools.py new file mode 100644 index 0000000..259e75e --- /dev/null +++ b/src/free_claude_code/core/openai_responses/tools.py @@ -0,0 +1,375 @@ +"""Tool conversion helpers for the OpenAI Responses adapter.""" + +import hashlib +import json +import re +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Literal + +from .errors import ResponsesConversionError +from .ids import new_call_id + +_MAX_ANTHROPIC_TOOL_NAME_LEN = 64 +_NAMESPACE_TOOL_SEPARATOR = "__" +_UNSUPPORTED_PASSIVE_TOOL_TYPES = frozenset( + {"web_search", "image_generation", "tool_search"} +) +_INVALID_TOOL_NAME_CHARS = re.compile(r"[^A-Za-z0-9_-]+") + + +@dataclass(frozen=True, slots=True) +class ResponsesToolIdentity: + kind: Literal["function", "custom"] + name: str + namespace: str | None = None + + +def convert_tools(value: Any) -> list[dict[str, Any]] | None: + if value is None: + return None + if not isinstance(value, list): + raise ResponsesConversionError("Responses tools must be a list") + + tools: list[dict[str, Any]] = [] + for tool in value: + if not isinstance(tool, dict): + raise ResponsesConversionError( + f"Unsupported Responses tool: {type(tool).__name__}" + ) + tool_type = tool.get("type") + if tool_type == "function": + tools.append(_convert_function_tool(tool, namespace=None)) + continue + if tool_type == "custom": + tools.append(_convert_custom_tool(tool, namespace=None)) + continue + if tool_type == "namespace": + tools.extend(_convert_namespace_tool(tool)) + continue + if tool_type in _UNSUPPORTED_PASSIVE_TOOL_TYPES: + continue + if tool_type != "function": + raise ResponsesConversionError( + f"Unsupported Responses tool type: {tool_type!r}" + ) + return tools + + +def convert_tool_choice(value: Any) -> dict[str, Any] | None: + if value is None or value == "auto": + return None + if value == "none": + return None + if value == "required": + return {"type": "any"} + if isinstance(value, dict): + choice_type = value.get("type") + if choice_type == "function": + namespace = optional_str(value.get("namespace")) + name = required_str(value.get("name"), "tool_choice.name") + return { + "type": "tool", + "name": responses_tool_name_to_anthropic_name( + name, namespace=namespace + ), + } + if choice_type == "custom": + source = _custom_source(value) + namespace = optional_str(source.get("namespace")) or optional_str( + value.get("namespace") + ) + name = required_str(source.get("name"), "tool_choice.name") + return { + "type": "tool", + "name": responses_tool_name_to_anthropic_name( + name, namespace=namespace + ), + } + if choice_type == "tool": + namespace = optional_str(value.get("namespace")) + name = optional_str(value.get("name")) + if name: + return { + "type": "tool", + "name": responses_tool_name_to_anthropic_name( + name, namespace=namespace + ), + } + return dict(value) + if choice_type in {"auto", "any"}: + return dict(value) + raise ResponsesConversionError(f"Unsupported Responses tool_choice: {value!r}") + + +def responses_tool_name_to_anthropic_name( + name: str, *, namespace: str | None = None +) -> str: + """Return a deterministic Anthropic tool name for a Responses tool identity.""" + + if not namespace: + return name + combined = ( + f"{_tool_name_part(namespace)}" + f"{_NAMESPACE_TOOL_SEPARATOR}" + f"{_tool_name_part(name)}" + ) + if len(combined) <= _MAX_ANTHROPIC_TOOL_NAME_LEN: + return combined + digest = hashlib.sha1(combined.encode("utf-8")).hexdigest()[:8] + prefix_len = _MAX_ANTHROPIC_TOOL_NAME_LEN - len(digest) - 1 + return f"{combined[:prefix_len]}_{digest}" + + +def responses_tool_identity_from_anthropic_name( + tools: list[dict[str, Any]] | None, anthropic_name: str +) -> ResponsesToolIdentity: + """Return the Responses namespace/name represented by an Anthropic tool name.""" + + if tools is None: + return ResponsesToolIdentity(kind="function", name=anthropic_name) + for tool in tools: + if not isinstance(tool, dict): + continue + tool_type = tool.get("type") + if tool_type == "function": + source = tool.get("function") + function = source if isinstance(source, dict) else tool + if (name := optional_str(function.get("name"))) and ( + responses_tool_name_to_anthropic_name(name) == anthropic_name + ): + return ResponsesToolIdentity(kind="function", name=name) + continue + if tool_type == "custom": + source = _custom_source(tool) + if (name := optional_str(source.get("name"))) and ( + responses_tool_name_to_anthropic_name(name) == anthropic_name + ): + return ResponsesToolIdentity(kind="custom", name=name) + continue + if tool_type != "namespace": + continue + namespace = optional_str(tool.get("name")) + nested_tools = tool.get("tools") + if not namespace or not isinstance(nested_tools, list): + continue + for nested_tool in nested_tools: + if not isinstance(nested_tool, dict): + continue + nested_tool_type = nested_tool.get("type") + if nested_tool_type == "function": + source = nested_tool.get("function") + function = source if isinstance(source, dict) else nested_tool + if (name := optional_str(function.get("name"))) and ( + responses_tool_name_to_anthropic_name(name, namespace=namespace) + == anthropic_name + ): + return ResponsesToolIdentity( + kind="function", name=name, namespace=namespace + ) + continue + if nested_tool_type == "custom": + source = _custom_source(nested_tool) + if (name := optional_str(source.get("name"))) and ( + responses_tool_name_to_anthropic_name(name, namespace=namespace) + == anthropic_name + ): + return ResponsesToolIdentity( + kind="custom", name=name, namespace=namespace + ) + return ResponsesToolIdentity(kind="function", name=anthropic_name) + + +def parse_arguments(value: Any) -> dict[str, Any]: + if value is None or value == "": + return {} + if isinstance(value, dict): + return value + if not isinstance(value, str): + raise ResponsesConversionError("Responses function_call arguments must be JSON") + try: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + raise ResponsesConversionError( + f"Responses function_call arguments are invalid JSON: {exc.msg}" + ) from exc + if not isinstance(parsed, dict): + raise ResponsesConversionError( + "Responses function_call arguments must decode to an object" + ) + return parsed + + +def normalized_function_call_arguments(value: Any) -> str: + return json.dumps(parse_arguments(value), separators=(",", ":")) + + +def custom_tool_input_to_anthropic(value: Any) -> dict[str, str]: + return {"input": custom_tool_input_text(value)} + + +def custom_tool_input_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + return _json_dumps(value) + + +def custom_tool_input_text_from_anthropic(value: Any) -> str: + if isinstance(value, Mapping): + raw_input = value.get("input") + if isinstance(raw_input, str): + return raw_input + if raw_input is not None: + return custom_tool_input_text(raw_input) + if not value: + return "" + return _json_dumps(value) + return custom_tool_input_text(value) + + +def custom_tool_input_text_from_arguments(arguments: str) -> str: + if not arguments: + return "" + try: + parsed = json.loads(arguments) + except json.JSONDecodeError: + return arguments + return custom_tool_input_text_from_anthropic(parsed) + + +def call_id_from_item(item: Mapping[str, Any]) -> str: + for key in ("call_id", "id"): + if value := optional_str(item.get(key)): + return value + return new_call_id() + + +def required_str(value: Any, field_name: str) -> str: + if isinstance(value, str) and value: + return value + raise ResponsesConversionError( + f"Responses field {field_name} must be a non-empty string" + ) + + +def optional_str(value: Any) -> str | None: + return value if isinstance(value, str) else None + + +def _convert_namespace_tool(tool: Mapping[str, Any]) -> list[dict[str, Any]]: + namespace = required_str(tool.get("name"), "tool.namespace.name") + nested_tools = tool.get("tools") + if not isinstance(nested_tools, list): + raise ResponsesConversionError( + f"Responses namespace tool {namespace!r} tools must be a list" + ) + + converted_tools: list[dict[str, Any]] = [] + for nested_tool in nested_tools: + if not isinstance(nested_tool, dict): + raise ResponsesConversionError( + f"Unsupported Responses namespace tool: {type(nested_tool).__name__}" + ) + nested_tool_type = nested_tool.get("type") + if nested_tool_type == "function": + converted_tools.append( + _convert_function_tool(nested_tool, namespace=namespace) + ) + continue + if nested_tool_type == "custom": + converted_tools.append( + _convert_custom_tool(nested_tool, namespace=namespace) + ) + continue + raise ResponsesConversionError( + f"Unsupported Responses namespace tool type: {nested_tool_type!r}" + ) + return converted_tools + + +def _convert_function_tool( + tool: Mapping[str, Any], *, namespace: str | None +) -> dict[str, Any]: + function = tool.get("function") + source = function if isinstance(function, dict) else tool + name = required_str(source.get("name"), "tool.name") + schema = source.get("parameters") + if schema is None: + schema = {"type": "object", "properties": {}} + if not isinstance(schema, dict): + raise ResponsesConversionError( + f"Responses tool {name!r} parameters must be an object" + ) + converted: dict[str, Any] = { + "name": responses_tool_name_to_anthropic_name(name, namespace=namespace), + "input_schema": schema, + } + if description := optional_str(source.get("description")): + converted["description"] = description + return converted + + +def _convert_custom_tool( + tool: Mapping[str, Any], *, namespace: str | None +) -> dict[str, Any]: + source = _custom_source(tool) + name = required_str(source.get("name"), "tool.name") + converted: dict[str, Any] = { + "name": responses_tool_name_to_anthropic_name(name, namespace=namespace), + "input_schema": { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Free-form input for the custom tool.", + } + }, + "required": ["input"], + }, + } + if description := _custom_tool_description(source): + converted["description"] = description + return converted + + +def _custom_source(tool: Mapping[str, Any]) -> Mapping[str, Any]: + custom = tool.get("custom") + return custom if isinstance(custom, Mapping) else tool + + +def _custom_tool_description(source: Mapping[str, Any]) -> str | None: + parts: list[str] = [] + if description := optional_str(source.get("description")): + parts.append(description) + format_value = source.get("format") + if isinstance(format_value, Mapping): + format_type = optional_str(format_value.get("type")) + if format_type == "text": + parts.append("Custom tool input format: unconstrained text.") + elif format_type == "grammar": + syntax = optional_str(format_value.get("syntax")) + definition = optional_str(format_value.get("definition")) + guidance = "Custom tool input format: grammar" + if syntax: + guidance = f"{guidance} ({syntax})" + guidance = f"{guidance}: {definition}" if definition else f"{guidance}." + parts.append(guidance) + elif format_type: + parts.append(f"Custom tool input format: {format_type}.") + else: + parts.append(f"Custom tool input format: {_json_dumps(format_value)}") + return "\n\n".join(parts) if parts else None + + +def _tool_name_part(value: str) -> str: + normalized = _INVALID_TOOL_NAME_CHARS.sub("_", value).strip("_") + return normalized or "tool" + + +def _json_dumps(value: Any) -> str: + try: + return json.dumps(value) + except TypeError: + return str(value) diff --git a/src/free_claude_code/core/openai_responses/usage.py b/src/free_claude_code/core/openai_responses/usage.py new file mode 100644 index 0000000..a6a2e28 --- /dev/null +++ b/src/free_claude_code/core/openai_responses/usage.py @@ -0,0 +1,35 @@ +"""Usage helpers for OpenAI Responses payloads.""" + +from typing import Protocol + +_DISALLOWED_SPECIAL: tuple[str, ...] = () + + +class _TokenEncoder(Protocol): + def encode( + self, text: str, *, disallowed_special: tuple[str, ...] + ) -> list[int]: ... + + +def _load_encoder() -> _TokenEncoder | None: + try: + import tiktoken + except ImportError: + return None + + try: + return tiktoken.get_encoding("cl100k_base") + except ValueError: + return None + + +_ENCODER = _load_encoder() + + +def estimate_text_tokens(text: str) -> int: + """Return a best-effort token estimate for Responses usage details.""" + if not text: + return 0 + if _ENCODER is not None: + return len(_ENCODER.encode(text, disallowed_special=_DISALLOWED_SPECIAL)) + return max(1, len(text) // 4) diff --git a/src/free_claude_code/core/rate_limit.py b/src/free_claude_code/core/rate_limit.py new file mode 100644 index 0000000..15bd9f4 --- /dev/null +++ b/src/free_claude_code/core/rate_limit.py @@ -0,0 +1,72 @@ +"""Shared strict sliding-window rate limiting primitives.""" + +import asyncio +import time +from collections import deque +from collections.abc import Callable + + +class StrictSlidingWindowLimiter: + """Strict sliding window limiter. + + Guarantees: at most ``rate_limit`` acquisitions in any interval of length + ``rate_window`` (seconds). + + Implemented as an async context manager so call sites can do:: + + async with limiter: + ... + """ + + def __init__(self, rate_limit: int, rate_window: float) -> None: + if rate_limit <= 0: + raise ValueError("rate_limit must be > 0") + if rate_window <= 0: + raise ValueError("rate_window must be > 0") + + self._rate_limit = int(rate_limit) + self._rate_window = float(rate_window) + self._times: deque[float] = deque() + self._lock = asyncio.Lock() + + async def acquire(self) -> None: + await self._acquire(None) + + async def acquire_if(self, allowed: Callable[[], bool]) -> bool: + """Record an acquisition only if ``allowed`` still holds at admission. + + Capacity is awaited first. The synchronous condition and timestamp write + then run without yielding, so a rejected admission consumes no quota. + """ + return await self._acquire(allowed) + + async def _acquire(self, allowed: Callable[[], bool] | None) -> bool: + while True: + wait_time = 0.0 + async with self._lock: + now = time.monotonic() + cutoff = now - self._rate_window + + while self._times and self._times[0] <= cutoff: + self._times.popleft() + + if len(self._times) < self._rate_limit: + if allowed is not None and not allowed(): + return False + self._times.append(time.monotonic()) + return True + + oldest = self._times[0] + wait_time = max(0.0, (oldest + self._rate_window) - now) + + if wait_time > 0: + await asyncio.sleep(wait_time) + else: + await asyncio.sleep(0) + + async def __aenter__(self) -> StrictSlidingWindowLimiter: + await self.acquire() + return self + + async def __aexit__(self, exc_type, exc, tb) -> bool: + return False diff --git a/src/free_claude_code/core/trace.py b/src/free_claude_code/core/trace.py new file mode 100644 index 0000000..713e633 --- /dev/null +++ b/src/free_claude_code/core/trace.py @@ -0,0 +1,196 @@ +"""Structured TRACE events for end-to-end request / CLI / provider logging. + +Emitted lines are merged into JSON log rows by ``config.logging_config``. +Conversation and Claude Code prompts are logged verbatim unless values live under +sanitized credential keys (e.g. ``api_key``, ``authorization``). +""" + +import asyncio +import sys +from collections.abc import AsyncGenerator, AsyncIterator, Mapping +from typing import Any + +from loguru import logger + +from free_claude_code.core.async_iterators import try_close_async_iterator + +TRACE_PAYLOAD_BINDING = "trace_payload" + +_SECRET_VALUE_KEYS = frozenset( + k.lower() + for k in ( + "authorization", + "x-api-key", + "anthropic-auth-token", + "api_key", + "password", + "secret", + "token", + "bearer_token", + "openapi_token", + "nvidia-api-key", + ) +) + + +def sanitize_trace_value(obj: Any) -> Any: + """Recursively copy JSON-like structures redacting credential-shaped keys.""" + if isinstance(obj, Mapping): + out: dict[str, Any] = {} + for k, v in obj.items(): + if str(k).lower() in _SECRET_VALUE_KEYS: + out[str(k)] = "" + else: + out[str(k)] = sanitize_trace_value(v) + return out + if isinstance(obj, tuple | list): + return [sanitize_trace_value(x) for x in obj] + return obj + + +def trace_event(*, stage: str, event: str, source: str, **fields: Any) -> None: + """Emit one structured TRACE row (merged into JSON by the log sink).""" + payload = sanitize_trace_value( + { + "stage": stage, + "event": event, + "source": source, + **fields, + }, + ) + logger.bind(trace_payload=payload).info("TRACE {}", event) + + +async def close_stream_input( + iterator: object, + *, + owner: str, + source: str, + preserved_error: BaseException | None, +) -> None: + """Close one transform input and observe cleanup failure without raising it.""" + close_error = await try_close_async_iterator(iterator) + if close_error is None: + return + trace_event( + stage="lifecycle", + event="stream.input.close_failed", + source=source, + owner=owner, + close_exc_type=type(close_error).__name__, + preserved_exc_type=( + type(preserved_error).__name__ if preserved_error is not None else None + ), + ) + + +def extract_claude_session_id_from_headers(headers: Mapping[str, str]) -> str | None: + """Best-effort session id forwarded by Claude Code / SDK via HTTP.""" + lowered = {str(k).lower(): v for k, v in headers.items() if isinstance(v, str)} + for key in ( + "anthropic-session-id", + "x-anthropic-session-id", + "claude-session-id", + "x-claude-session-id", + ): + candidate = lowered.get(key) + if candidate: + return candidate + return None + + +async def traced_async_stream( + agen: AsyncIterator[str], + *, + stage: str, + source: str, + complete_event: str, + interrupted_event: str, + chunk_event: str | None = None, + chunk_interval: int = 250, + extra: Mapping[str, Any] | None = None, +) -> AsyncGenerator[str]: + """Emit TRACE rows when a text stream completes, fails, cancels, or periodically.""" + common = dict(extra or {}) + count = 0 + nbytes = 0 + interrupted = False + try: + async for chunk in agen: + count += 1 + nbytes += len(chunk.encode("utf-8", errors="replace")) + if chunk_event and chunk_interval > 0 and count % chunk_interval == 0: + trace_event( + stage=stage, + event=chunk_event, + source=source, + stream_chunks_so_far=count, + stream_bytes_so_far=nbytes, + **common, + ) + yield chunk + except GeneratorExit: + raise + except asyncio.CancelledError: + interrupted = True + trace_event( + stage=stage, + event=interrupted_event, + source=source, + stream_chunks=count, + stream_bytes=nbytes, + outcome="cancelled", + **common, + ) + raise + except BaseExceptionGroup as grp: + interrupted = True + trace_event( + stage=stage, + event=interrupted_event, + source=source, + stream_chunks=count, + stream_bytes=nbytes, + outcome="exception_group", + note=str(grp), + **common, + ) + raise + except Exception as exc: + interrupted = True + trace_event( + stage=stage, + event=interrupted_event, + source=source, + stream_chunks=count, + stream_bytes=nbytes, + outcome="error", + exc_type=type(exc).__name__, + **common, + ) + raise + finally: + await close_stream_input( + agen, + owner="traced_async_stream", + source=source, + preserved_error=sys.exception(), + ) + + if not interrupted: + trace_event( + stage=stage, + event=complete_event, + source=source, + stream_chunks=count, + stream_bytes=nbytes, + outcome="ok", + **common, + ) + + +def provider_chat_body_snapshot(body: Mapping[str, Any]) -> dict[str, Any]: + """Sanitized OpenAI-compat chat body subset for traces (conversation text verbatim).""" + keys = ("model", "messages", "tools", "tool_choice", "temperature", "max_tokens") + snap = {k: body[k] for k in keys if k in body and body[k] is not None} + return sanitize_trace_value(snap) diff --git a/src/free_claude_code/core/version.py b/src/free_claude_code/core/version.py new file mode 100644 index 0000000..42a122c --- /dev/null +++ b/src/free_claude_code/core/version.py @@ -0,0 +1,15 @@ +"""Canonical installed Free Claude Code package version.""" + +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as distribution_version + +_DISTRIBUTION_NAME = "free-claude-code" +_UNKNOWN_VERSION = "0+unknown" + + +def package_version() -> str: + """Return installed metadata, or an explicit source-only fallback.""" + try: + return distribution_version(_DISTRIBUTION_NAME) + except PackageNotFoundError: + return _UNKNOWN_VERSION diff --git a/src/free_claude_code/messaging/__init__.py b/src/free_claude_code/messaging/__init__.py new file mode 100644 index 0000000..40f0461 --- /dev/null +++ b/src/free_claude_code/messaging/__init__.py @@ -0,0 +1,16 @@ +"""Platform-agnostic messaging layer.""" + +from .managed_protocols import ( + ManagedClaudeSessionManagerProtocol, + ManagedClaudeSessionProtocol, +) +from .models import IncomingMessage, MessageScope +from .platforms.ports import OutboundMessenger + +__all__ = [ + "IncomingMessage", + "ManagedClaudeSessionManagerProtocol", + "ManagedClaudeSessionProtocol", + "MessageScope", + "OutboundMessenger", +] diff --git a/src/free_claude_code/messaging/cli_event_constants.py b/src/free_claude_code/messaging/cli_event_constants.py new file mode 100644 index 0000000..3d531ec --- /dev/null +++ b/src/free_claude_code/messaging/cli_event_constants.py @@ -0,0 +1,67 @@ +"""CLI event types and status-line mapping for transcript / UI updates.""" + +from collections.abc import Callable +from typing import Any + +# Status message prefixes used to filter our own messages (ignore echo) +STATUS_MESSAGE_PREFIXES = ( + "⏳", + "💭", + "🔧", + "✅", + "❌", + "🚀", + "🤖", + "📋", + "📊", + "🔄", +) + +# Event types that update the transcript (frozenset for O(1) membership) +TRANSCRIPT_EVENT_TYPES = frozenset( + { + "thinking_start", + "thinking_delta", + "thinking_chunk", + "thinking_stop", + "text_start", + "text_delta", + "text_chunk", + "text_stop", + "tool_use_start", + "tool_use_delta", + "tool_use_stop", + "tool_use", + "tool_result", + "block_stop", + "error", + } +) + +# Event type -> (emoji, label) for status updates (O(1) lookup) +_EVENT_STATUS_MAP: dict[str, tuple[str, str]] = { + "thinking_start": ("🧠", "Claude is thinking..."), + "thinking_delta": ("🧠", "Claude is thinking..."), + "thinking_chunk": ("🧠", "Claude is thinking..."), + "text_start": ("🧠", "Claude is working..."), + "text_delta": ("🧠", "Claude is working..."), + "text_chunk": ("🧠", "Claude is working..."), + "tool_result": ("⏳", "Executing tools..."), +} + + +def get_status_for_event( + ptype: str, + parsed: dict[str, Any], + format_status_fn: Callable[..., str], +) -> str | None: + """Return status string for event type, or None if no status update needed.""" + entry = _EVENT_STATUS_MAP.get(ptype) + if entry is not None: + emoji, label = entry + return format_status_fn(emoji, label) + if ptype in ("tool_use_start", "tool_use_delta", "tool_use"): + if parsed.get("name") == "Task": + return format_status_fn("🤖", "Subagent working...") + return format_status_fn("⏳", "Executing tools...") + return None diff --git a/src/free_claude_code/messaging/command_context.py b/src/free_claude_code/messaging/command_context.py new file mode 100644 index 0000000..5a46f52 --- /dev/null +++ b/src/free_claude_code/messaging/command_context.py @@ -0,0 +1,99 @@ +"""Typed dependency surface for messaging slash commands.""" + +from dataclasses import dataclass +from typing import Protocol + +from .managed_protocols import ManagedClaudeSessionManagerProtocol +from .models import MessageScope +from .platforms.ports import OutboundMessenger +from .transcript import RenderCtx + + +@dataclass(frozen=True, slots=True) +class ReplyClearResult: + """Customer-facing result of clearing one literal reply subtree.""" + + delete_message_ids: frozenset[str] + tree_matched: bool + + +@dataclass(frozen=True, slots=True) +class StopOutcome: + """Customer-facing stop result after terminal status ownership is assigned.""" + + cancelled_count: int + status_feedback_scopes: frozenset[MessageScope] + fallback_required: bool + + def requires_confirmation(self, scope: MessageScope) -> bool: + """Return whether this scope lacks complete terminal status feedback.""" + return ( + self.cancelled_count == 0 + or self.fallback_required + or self.status_feedback_scopes != frozenset({scope}) + ) + + +class MessagingCommandContext(Protocol): + """Operations commands need from the messaging workflow.""" + + outbound: OutboundMessenger + cli_manager: ManagedClaudeSessionManagerProtocol + + def format_status(self, emoji: str, label: str, suffix: str | None = None) -> str: + """Format a platform-specific status line.""" + ... + + def get_render_ctx(self) -> RenderCtx: + """Return the render context for command output.""" + ... + + async def stop_all_tasks(self) -> StopOutcome: + """Stop every pending or active messaging task.""" + ... + + async def stop_reply( + self, + scope: MessageScope, + reply_id: str, + ) -> StopOutcome: + """Stop the exact voice/tree owner of a replied-to message.""" + ... + + def get_tree_count(self) -> int: + """Return the number of conversation trees.""" + ... + + async def clear_reply( + self, + scope: MessageScope, + reply_id: str, + ) -> ReplyClearResult | None: + """Clear the literal subtree rooted at a replied-to message.""" + ... + + async def clear_chat(self, platform: str, chat_id: str) -> frozenset[str]: + """Clear one chat and return every tracked platform message ID.""" + ... + + def forget_tracked_message_ids( + self, + platform: str, + chat_id: str, + message_ids: set[str], + ) -> None: + """Forget platform message IDs removed from the managed conversation.""" + ... + + def record_outgoing_message( + self, + platform: str, + chat_id: str, + msg_id: str | None, + kind: str, + ) -> bool: + """Record an outgoing platform message ID and report registry ownership.""" + ... + + +__all__ = ["MessagingCommandContext", "ReplyClearResult", "StopOutcome"] diff --git a/src/free_claude_code/messaging/command_dispatcher.py b/src/free_claude_code/messaging/command_dispatcher.py new file mode 100644 index 0000000..31d30b6 --- /dev/null +++ b/src/free_claude_code/messaging/command_dispatcher.py @@ -0,0 +1,31 @@ +"""Command parsing and dispatch for messaging handlers.""" + +from .command_context import MessagingCommandContext +from .commands import handle_clear_command, handle_stats_command, handle_stop_command +from .models import IncomingMessage + +_COMMAND_HANDLERS = { + "/clear": handle_clear_command, + "/stop": handle_stop_command, + "/stats": handle_stats_command, +} + + +def parse_command_base(text: str | None) -> str: + """Return the slash command without bot mention suffix.""" + parts = (text or "").strip().split() + cmd = parts[0] if parts else "" + return cmd.split("@", 1)[0] if cmd else "" + + +async def dispatch_command( + context: MessagingCommandContext, + incoming: IncomingMessage, + command_base: str, +) -> bool: + """Dispatch a known command and return whether it was handled.""" + command = _COMMAND_HANDLERS.get(command_base) + if command is None: + return False + await command(context, incoming) + return True diff --git a/src/free_claude_code/messaging/commands.py b/src/free_claude_code/messaging/commands.py new file mode 100644 index 0000000..fd042ca --- /dev/null +++ b/src/free_claude_code/messaging/commands.py @@ -0,0 +1,182 @@ +"""Command handlers for messaging platform commands (/stop, /stats, /clear). + +Commands depend on MessagingCommandContext instead of the concrete workflow. +""" + +from loguru import logger + +from .command_context import MessagingCommandContext +from .models import IncomingMessage + + +async def _send_stop_feedback( + handler: MessagingCommandContext, + incoming: IncomingMessage, + suffix: str, +) -> None: + """Send stop feedback only when no existing status can represent the result.""" + msg_id = await handler.outbound.queue_send_message( + incoming.chat_id, + handler.format_status("⏹", "Stopped.", suffix), + fire_and_forget=False, + message_thread_id=incoming.message_thread_id, + ) + handler.record_outgoing_message( + incoming.platform, incoming.chat_id, msg_id, "command" + ) + + +async def handle_stop_command( + handler: MessagingCommandContext, incoming: IncomingMessage +) -> None: + """Handle /stop command from messaging platform.""" + # Reply-scoped stop: reply "/stop" to stop only that task. + if incoming.is_reply() and incoming.reply_to_message_id: + outcome = await handler.stop_reply( + incoming.scope, + incoming.reply_to_message_id, + ) + + if outcome.cancelled_count == 0: + await _send_stop_feedback( + handler, + incoming, + "Nothing to stop for that message.", + ) + return + + if outcome.requires_confirmation(incoming.scope): + noun = "request" if outcome.cancelled_count == 1 else "requests" + await _send_stop_feedback( + handler, + incoming, + f"Cancelled {outcome.cancelled_count} {noun}.", + ) + return + + # Global stop: legacy behavior (stop everything) + outcome = await handler.stop_all_tasks() + if outcome.cancelled_count == 0: + await _send_stop_feedback(handler, incoming, "Nothing to stop.") + elif outcome.requires_confirmation(incoming.scope): + noun = "request" if outcome.cancelled_count == 1 else "requests" + await _send_stop_feedback( + handler, + incoming, + f"Cancelled {outcome.cancelled_count} pending or active {noun}.", + ) + + +async def handle_stats_command( + handler: MessagingCommandContext, incoming: IncomingMessage +) -> None: + """Handle /stats command.""" + stats = handler.cli_manager.get_stats() + tree_count = handler.get_tree_count() + ctx = handler.get_render_ctx() + msg_id = await handler.outbound.queue_send_message( + incoming.chat_id, + "📊 " + + ctx.bold("Stats") + + "\n" + + ctx.escape_text(f"• Active CLI: {stats['active_sessions']}") + + "\n" + + ctx.escape_text(f"• Message Trees: {tree_count}"), + fire_and_forget=False, + message_thread_id=incoming.message_thread_id, + ) + handler.record_outgoing_message( + incoming.platform, incoming.chat_id, msg_id, "command" + ) + + +async def _delete_message_ids( + handler: MessagingCommandContext, chat_id: str, msg_ids: set[str] +) -> None: + """Best-effort delete messages by ID. Sorts numeric IDs descending.""" + if not msg_ids: + return + + def _as_int(s: str) -> int | None: + try: + return int(str(s)) + except Exception: + return None + + numeric: list[tuple[int, str]] = [] + non_numeric: list[str] = [] + for mid in msg_ids: + n = _as_int(mid) + if n is None: + non_numeric.append(mid) + else: + numeric.append((n, mid)) + numeric.sort(reverse=True) + non_numeric.sort(reverse=True) + ordered = [mid for _, mid in numeric] + non_numeric + + failed = 0 + try: + await handler.outbound.queue_delete_messages( + chat_id, + ordered, + fire_and_forget=False, + ) + except Exception as e: + failed = len(ordered) + logger.debug("Message delete failed for chat {}: {}", chat_id, type(e).__name__) + + if ordered: + logger.info( + "Clear delete attempted={} failed={}", + len(ordered), + failed, + ) + + +async def handle_clear_command( + handler: MessagingCommandContext, incoming: IncomingMessage +) -> None: + """ + Handle /clear command. + + Reply-scoped: delete the selected message and its literal reply subtree. + Standalone: reset and delete the invoking chat's managed conversation. + """ + if incoming.is_reply() and incoming.reply_to_message_id: + result = await handler.clear_reply( + incoming.scope, + incoming.reply_to_message_id, + ) + if result is None: + msg_id = await handler.outbound.queue_send_message( + incoming.chat_id, + handler.format_status( + "🗑", "Cleared.", "Nothing to clear for that message." + ), + fire_and_forget=False, + message_thread_id=incoming.message_thread_id, + ) + handler.record_outgoing_message( + incoming.platform, incoming.chat_id, msg_id, "command" + ) + return + + delete_message_ids = set(result.delete_message_ids) + if incoming.message_id is not None: + delete_message_ids.add(str(incoming.message_id)) + await _delete_message_ids(handler, incoming.chat_id, delete_message_ids) + handler.forget_tracked_message_ids( + incoming.platform, + incoming.chat_id, + delete_message_ids, + ) + return + + msg_ids = set(await handler.clear_chat(incoming.platform, incoming.chat_id)) + + # Also delete the command message itself. + if incoming.message_id is not None: + msg_ids.add(str(incoming.message_id)) + + await _delete_message_ids(handler, incoming.chat_id, msg_ids) diff --git a/src/free_claude_code/messaging/event_parser.py b/src/free_claude_code/messaging/event_parser.py new file mode 100644 index 0000000..14b30e8 --- /dev/null +++ b/src/free_claude_code/messaging/event_parser.py @@ -0,0 +1,184 @@ +"""CLI event parser for Claude Code CLI output. + +This parser emits an ordered stream of low-level events suitable for building a +Claude Code-like transcript in messaging UIs. +""" + +from typing import Any + +from loguru import logger + + +def parse_cli_event(event: Any, *, log_raw_cli: bool = False) -> list[dict]: + """ + Parse a CLI event and return a structured result. + + Args: + event: Raw event dictionary from CLI + log_raw_cli: When True, log full error text from the CLI. Default is + metadata-only (lengths / exit codes) to avoid leaking user content. + + Returns: + List of parsed event dicts. Empty list if not recognized. + """ + if not isinstance(event, dict): + return [] + + etype = event.get("type") + results: list[dict[str, Any]] = [] + + # Some CLI/proxy layers emit "system" events that are not user-visible and + # carry no transcript content. Ignore them explicitly to avoid noisy logs. + if etype == "system": + return [] + + # 1. Handle full messages (assistant/user or result) + msg_obj = None + if etype == "assistant" or etype == "user": + msg_obj = event.get("message") + elif etype == "result": + res = event.get("result") + if isinstance(res, dict): + msg_obj = res.get("message") + # Some variants put content directly on the result. + if not msg_obj and isinstance(res.get("content"), list): + msg_obj = {"content": res.get("content")} + if not msg_obj: + msg_obj = event.get("message") + # Some variants put content directly on the event. + if not msg_obj and isinstance(event.get("content"), list): + msg_obj = {"content": event.get("content")} + + if msg_obj and isinstance(msg_obj, dict): + content = msg_obj.get("content", []) + if isinstance(content, list): + # Preserve order exactly as content blocks appear. + for c in content: + if not isinstance(c, dict): + continue + ctype = c.get("type") + if ctype == "text": + results.append({"type": "text_chunk", "text": c.get("text", "")}) + elif ctype == "thinking": + results.append( + {"type": "thinking_chunk", "text": c.get("thinking", "")} + ) + elif ctype == "tool_use": + results.append( + { + "type": "tool_use", + "id": str(c.get("id", "") or "").strip(), + "name": c.get("name", ""), + "input": c.get("input"), + } + ) + elif ctype == "tool_result": + results.append( + { + "type": "tool_result", + "tool_use_id": str(c.get("tool_use_id", "") or "").strip(), + "content": c.get("content"), + "is_error": bool(c.get("is_error", False)), + } + ) + + if results: + return results + + # 2. Handle streaming deltas + if etype == "content_block_delta": + delta = event.get("delta", {}) + if isinstance(delta, dict): + if delta.get("type") == "text_delta": + return [ + { + "type": "text_delta", + "index": event.get("index", -1), + "text": delta.get("text", ""), + } + ] + if delta.get("type") == "thinking_delta": + return [ + { + "type": "thinking_delta", + "index": event.get("index", -1), + "text": delta.get("thinking", ""), + } + ] + if delta.get("type") == "input_json_delta": + return [ + { + "type": "tool_use_delta", + "index": event.get("index", -1), + "partial_json": delta.get("partial_json", ""), + } + ] + + # 3. Handle tool usage start + if etype == "content_block_start": + block = event.get("content_block", {}) + if isinstance(block, dict): + btype = block.get("type") + if btype == "thinking": + return [{"type": "thinking_start", "index": event.get("index", -1)}] + if btype == "text": + return [{"type": "text_start", "index": event.get("index", -1)}] + if btype == "tool_use": + return [ + { + "type": "tool_use_start", + "index": event.get("index", -1), + "id": str(block.get("id", "") or "").strip(), + "name": block.get("name", ""), + "input": block.get("input"), + } + ] + + # 3.5 Handle block stop (to close open streaming segments) + if etype == "content_block_stop": + return [{"type": "block_stop", "index": event.get("index", -1)}] + + # 4. Handle errors and exit + if etype == "error": + err = event.get("error") + msg = err.get("message") if isinstance(err, dict) else str(err) + if log_raw_cli: + logger.info("CLI_PARSER: Parsed error event: {}", msg) + else: + mlen = len(msg) if isinstance(msg, str) else 0 + logger.info("CLI_PARSER: Parsed error event: message_chars={}", mlen) + return [{"type": "error", "message": msg}] + elif etype == "exit": + code = event.get("code", 0) + stderr = event.get("stderr") + if code == 0: + logger.debug(f"CLI_PARSER: Successful exit (code={code})") + return [{"type": "complete", "status": "success"}] + + error_msg = stderr if stderr else f"Process exited with code {code}" + if log_raw_cli: + logger.warning( + "CLI_PARSER: Error exit (code={}): {}", + code, + error_msg, + ) + else: + em = error_msg if isinstance(error_msg, str) else str(error_msg) + logger.warning( + "CLI_PARSER: Error exit (code={}): message_chars={}", + code, + len(em), + ) + return [ + { + "type": "error", + "message": error_msg, + "source": "exit", + "exit_code": code, + } + ] + + # Log unrecognized events for debugging + if etype: + logger.debug(f"CLI_PARSER: Unrecognized event type: {etype}") + return [] diff --git a/src/free_claude_code/messaging/limiter.py b/src/free_claude_code/messaging/limiter.py new file mode 100644 index 0000000..584289e --- /dev/null +++ b/src/free_claude_code/messaging/limiter.py @@ -0,0 +1,342 @@ +"""Runtime-owned queued delivery for one messaging platform.""" + +import asyncio +from collections import deque +from collections.abc import Awaitable, Callable +from typing import Any + +from loguru import logger + +from free_claude_code.core.rate_limit import ( + StrictSlidingWindowLimiter as SlidingWindowLimiter, +) + +from .safe_diagnostics import format_exception_for_log + + +class MessagingRateLimiter: + """ + Rate limiter and compacting work queue for one messaging runtime. + + Uses a custom queue with task compaction (deduplication) to ensure + only the latest version of a message update is processed. + """ + + def __init__( + self, + *, + rate_limit: int, + rate_window: float, + log_error_details: bool = False, + ) -> None: + self.limiter = SlidingWindowLimiter(rate_limit, rate_window) + self._log_error_details = log_error_details + # Custom queue state - using deque for O(1) popleft + self._queue_list: deque[str] = deque() # Deque of dedup_keys in order + self._queue_map: dict[ + str, tuple[Callable[[], Awaitable[Any]], list[asyncio.Future]] + ] = {} + self._condition = asyncio.Condition() + self._shutdown = asyncio.Event() + self._worker_task: asyncio.Task[None] | None = None + self._background_tasks: set[asyncio.Task[None]] = set() + self._active_futures: list[asyncio.Future[Any]] = [] + self._closed = False + self._paused_until = 0 + + logger.info( + f"MessagingRateLimiter initialized ({rate_limit} req / {rate_window}s with Task Compaction)" + ) + + def start(self) -> None: + """Start the owned worker on the current event loop.""" + if self._closed: + raise RuntimeError("Messaging rate limiter is closed.") + if self._worker_task and not self._worker_task.done(): + return + self._worker_task = asyncio.create_task( + self._worker(), name="msg-limiter-worker" + ) + + async def _worker(self) -> None: + """Background worker that processes queued messaging tasks.""" + logger.info("MessagingRateLimiter worker started") + while not self._shutdown.is_set(): + try: + # Get a task from the queue + async with self._condition: + while not self._queue_list and not self._shutdown.is_set(): + await self._condition.wait() + + if self._shutdown.is_set(): + break + + dedup_key = self._queue_list.popleft() + func, futures = self._queue_map.pop(dedup_key) + self._active_futures = futures + + # Check for manual pause (FloodWait) + now = asyncio.get_event_loop().time() + if self._paused_until > now: + wait_time = self._paused_until - now + logger.warning( + f"Limiter worker paused, waiting {wait_time:.1f}s more..." + ) + await asyncio.sleep(wait_time) + + # Wait for rate limit capacity + async with self.limiter: + try: + result = await func() + for f in futures: + if not f.done(): + f.set_result(result) + except asyncio.CancelledError: + for f in futures: + if not f.done(): + f.cancel() + worker = asyncio.current_task() + if self._shutdown.is_set() or ( + worker is not None and worker.cancelling() + ): + raise + logger.debug( + "Messaging operation cancelled for key {}; worker remains active", + dedup_key, + ) + except Exception as e: + # Report error to all futures and log it + for f in futures: + if not f.done(): + f.set_exception(e) + + error_msg = str(e).lower() + if "flood" in error_msg or "wait" in error_msg: + seconds = 30 + try: + if hasattr(e, "seconds"): + seconds = e.seconds + elif "after " in error_msg: + # Try to parse "retry after X" + parts = error_msg.split("after ") + if len(parts) > 1: + seconds = int(parts[1].split()[0]) + except Exception: + pass + + logger.error( + f"FloodWait detected! Pausing worker for {seconds}s" + ) + wait_secs = ( + float(seconds) + if isinstance(seconds, (int, float, str)) + else 30.0 + ) + self._paused_until = ( + asyncio.get_event_loop().time() + wait_secs + ) + else: + logger.error( + "Error in limiter worker for key {}: {}", + dedup_key, + format_exception_for_log( + e, + log_full_message=self._log_error_details, + ), + ) + finally: + self._active_futures = [] + except asyncio.CancelledError: + for future in self._active_futures: + if not future.done(): + future.cancel() + self._active_futures = [] + if self._shutdown.is_set(): + break + raise + except Exception as e: + if self._log_error_details: + logger.error( + "MessagingRateLimiter worker critical error: {}", + e, + exc_info=True, + ) + else: + logger.error( + "MessagingRateLimiter worker critical error: exc_type={}", + type(e).__name__, + ) + await asyncio.sleep(1) + + async def shutdown(self, timeout: float | None = None) -> None: + """Cancel queued work and stop every task owned by this limiter.""" + self._closed = True + self._shutdown.set() + async with self._condition: + queued_futures = [ + future + for _func, futures in self._queue_map.values() + for future in futures + ] + self._queue_list.clear() + self._queue_map.clear() + for future in queued_futures: + if not future.done(): + future.cancel() + for future in self._active_futures: + if not future.done(): + future.cancel() + self._condition.notify_all() + + cancellation: asyncio.CancelledError | None = None + timeout_error: TimeoutError | None = None + task = self._worker_task + if task and not task.done(): + task.cancel() + try: + drain = asyncio.gather(task, return_exceptions=True) + if timeout is None: + await drain + else: + await asyncio.wait_for(drain, timeout=timeout) + except TimeoutError as exc: + timeout_error = exc + except asyncio.CancelledError as exc: + cancellation = exc + if task is None or task.done(): + self._worker_task = None + + background_tasks = tuple(self._background_tasks) + for background_task in background_tasks: + background_task.cancel() + if background_tasks: + try: + await asyncio.gather(*background_tasks, return_exceptions=True) + except asyncio.CancelledError as exc: + cancellation = exc + self._background_tasks.difference_update( + task for task in background_tasks if task.done() + ) + + if cancellation is not None: + raise cancellation + if timeout_error is not None: + raise TimeoutError( + "MessagingRateLimiter worker did not stop before timeout" + ) from timeout_error + + async def _enqueue_internal( + self, + func: Callable[[], Awaitable[Any]], + future: asyncio.Future[Any], + dedup_key: str, + front: bool = False, + ) -> None: + await self._enqueue_internal_multi(func, [future], dedup_key, front) + + async def _enqueue_internal_multi( + self, + func: Callable[[], Awaitable[Any]], + futures: list[asyncio.Future[Any]], + dedup_key: str, + front: bool = False, + ) -> None: + async with self._condition: + if self._closed: + raise RuntimeError("Messaging rate limiter is closed.") + if self._worker_task is None or self._worker_task.done(): + raise RuntimeError("Messaging rate limiter has not been started.") + if dedup_key in self._queue_map: + # Compaction: Update existing task with new func, append new futures + _old_func, old_futures = self._queue_map[dedup_key] + old_futures.extend(futures) + self._queue_map[dedup_key] = (func, old_futures) + logger.debug( + f"Compacted task for key: {dedup_key} (now {len(old_futures)} futures)" + ) + else: + self._queue_map[dedup_key] = (func, futures) + if front: + self._queue_list.appendleft(dedup_key) + else: + self._queue_list.append(dedup_key) + self._condition.notify_all() + + async def enqueue( + self, func: Callable[[], Awaitable[Any]], dedup_key: str | None = None + ) -> Any: + """ + Enqueue a messaging task and return its future result. + If dedup_key is provided, subsequent tasks with the same key will replace this one. + """ + self._require_running() + if dedup_key is None: + # Unique key to avoid deduplication + dedup_key = f"task_{id(func)}_{asyncio.get_running_loop().time()}" + + future = asyncio.get_running_loop().create_future() + try: + await self._enqueue_internal(func, future, dedup_key) + except BaseException: + future.cancel() + raise + return await future + + def fire_and_forget( + self, func: Callable[[], Awaitable[Any]], dedup_key: str | None = None + ) -> None: + """Enqueue a task without waiting for the result.""" + self._require_running() + if dedup_key is None: + dedup_key = f"task_{id(func)}_{asyncio.get_running_loop().time()}" + + async def _wrapped() -> None: + max_retries = 2 + for attempt in range(max_retries + 1): + try: + await self.enqueue(func, dedup_key) + return + except Exception as e: + error_msg = str(e).lower() + # Only retry transient connectivity issues that might have slipped through + # or occurred between platform checks. + if attempt < max_retries and any( + x in error_msg for x in ["connect", "timeout", "broken"] + ): + wait = 2**attempt + if self._log_error_details: + logger.warning( + "Limiter fire_and_forget transient error (attempt {}): {}. Retrying in {}s...", + attempt + 1, + e, + wait, + ) + else: + logger.warning( + "Limiter fire_and_forget transient error (attempt {}): exc_type={}. Retrying in {}s...", + attempt + 1, + type(e).__name__, + wait, + ) + await asyncio.sleep(wait) + continue + + logger.error( + "Final error in fire_and_forget for key {}: {}", + dedup_key, + format_exception_for_log( + e, + log_full_message=self._log_error_details, + ), + ) + break + + task = asyncio.create_task(_wrapped(), name=f"msg-limiter:{dedup_key}") + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + def _require_running(self) -> None: + if self._closed: + raise RuntimeError("Messaging rate limiter is closed.") + if self._worker_task is None or self._worker_task.done(): + raise RuntimeError("Messaging rate limiter has not been started.") diff --git a/src/free_claude_code/messaging/managed_protocols.py b/src/free_claude_code/messaging/managed_protocols.py new file mode 100644 index 0000000..e149880 --- /dev/null +++ b/src/free_claude_code/messaging/managed_protocols.py @@ -0,0 +1,35 @@ +"""Protocols for messaging-owned managed Claude sessions.""" + +from collections.abc import AsyncGenerator +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class ManagedClaudeSessionProtocol(Protocol): + """Protocol for managed Claude sessions used by messaging.""" + + def start_task( + self, prompt: str, session_id: str | None = None, fork_session: bool = False + ) -> AsyncGenerator[dict, Any]: ... + + @property + def is_busy(self) -> bool: ... + + +@runtime_checkable +class ManagedClaudeSessionManagerProtocol(Protocol): + """Protocol for the managed Claude session pool used by messaging.""" + + async def get_or_create_session( + self, session_id: str | None = None + ) -> tuple[ManagedClaudeSessionProtocol, str, bool]: ... + + async def register_real_session_id( + self, temp_id: str, real_session_id: str + ) -> bool: ... + + async def stop_all(self) -> None: ... + + async def remove_session(self, session_id: str) -> bool: ... + + def get_stats(self) -> dict: ... diff --git a/src/free_claude_code/messaging/models.py b/src/free_claude_code/messaging/models.py new file mode 100644 index 0000000..07a7541 --- /dev/null +++ b/src/free_claude_code/messaging/models.py @@ -0,0 +1,57 @@ +"""Platform-agnostic message models.""" + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + + +@dataclass(frozen=True, slots=True) +class MessageScope: + """Platform chat namespace in which message IDs are unique.""" + + platform: str + chat_id: str + + +@dataclass(frozen=True, slots=True) +class AdmissionToken: + """Generations that must still match when a turn commits.""" + + stop_generation: int + clear_generation: int + + +@dataclass +class IncomingMessage: + """ + Platform-agnostic incoming message. + + Adapters convert platform-specific events to this format. + """ + + text: str + chat_id: str + user_id: str + message_id: str + platform: str # "telegram", "discord", "slack", etc. + + # Optional fields + reply_to_message_id: str | None = None + # Forum topic ID (Telegram); required when replying in forum supergroups + message_thread_id: str | None = None + username: str | None = None + # Pre-sent status message ID (e.g. "Transcribing voice note..."); handler edits in place + status_message_id: str | None = None + timestamp: datetime = field(default_factory=lambda: datetime.now(UTC)) + + # Platform-specific raw event stays at ingress and is never persisted by trees. + raw_event: Any = None + + def is_reply(self) -> bool: + """Check if this message is a reply to another message.""" + return self.reply_to_message_id is not None + + @property + def scope(self) -> MessageScope: + """Return the namespace that owns this message's platform IDs.""" + return MessageScope(platform=str(self.platform), chat_id=str(self.chat_id)) diff --git a/src/free_claude_code/messaging/node_event_pipeline.py b/src/free_claude_code/messaging/node_event_pipeline.py new file mode 100644 index 0000000..dc7d4d5 --- /dev/null +++ b/src/free_claude_code/messaging/node_event_pipeline.py @@ -0,0 +1,120 @@ +"""CLI event handling for a single queued node (transcript + session + errors).""" + +from collections.abc import Awaitable, Callable +from typing import Any + +from loguru import logger + +from free_claude_code.core.trace import trace_event + +from .cli_event_constants import TRANSCRIPT_EVENT_TYPES, get_status_for_event +from .managed_protocols import ManagedClaudeSessionManagerProtocol +from .safe_diagnostics import text_len_hint +from .transcript import TranscriptBuffer +from .trees import NodeClaim + +RecordSession = Callable[[str], Awaitable[None]] +CompleteClaim = Callable[[str | None], Awaitable[None]] +FailClaim = Callable[[str, str], Awaitable[None]] + + +async def handle_session_info_event( + event_data: dict[str, Any], + claim: NodeClaim, + captured_session_id: str | None, + temp_session_id: str | None, + *, + cli_manager: ManagedClaudeSessionManagerProtocol, + record_session: RecordSession, +) -> tuple[str | None, str | None]: + """Handle session_info event; return updated (captured_session_id, temp_session_id).""" + if event_data.get("type") != "session_info": + return captured_session_id, temp_session_id + + real_session_id = event_data.get("session_id") + if not real_session_id or not temp_session_id: + return captured_session_id, temp_session_id + + registered = await cli_manager.register_real_session_id( + temp_session_id, + real_session_id, + ) + if not registered: + raise RuntimeError("Managed Claude session registration failed.") + trace_event( + stage="claude_cli", + event="claude_cli.session.registered", + source="claude_cli", + node_id=claim.node.node_id, + temp_session_id=temp_session_id, + real_session_id=real_session_id, + tree_root_id=claim.identity.root_id, + ) + await record_session(real_session_id) + + return real_session_id, None + + +async def process_parsed_cli_event( + parsed: dict[str, Any], + transcript: TranscriptBuffer, + update_ui: Callable[..., Awaitable[None]], + last_status: str | None, + had_transcript_events: bool, + claim: NodeClaim, + captured_session_id: str | None, + *, + format_status: Callable[..., str], + complete_claim: CompleteClaim, + fail_claim: FailClaim, + log_messaging_error_details: bool = False, +) -> tuple[str | None, bool]: + """Process a single parsed CLI event. Returns (last_status, had_transcript_events).""" + ptype = parsed.get("type") or "" + + if ptype in TRANSCRIPT_EVENT_TYPES: + transcript.apply(parsed) + had_transcript_events = True + + status = get_status_for_event(ptype, parsed, format_status) + if status is not None: + await update_ui(status) + last_status = status + elif ptype == "block_stop": + await update_ui(last_status, force=True) + elif ptype == "complete": + if parsed.get("status") != "success": + return last_status, had_transcript_events + if not had_transcript_events: + transcript.apply({"type": "text_chunk", "text": "Done."}) + trace_event( + stage="claude_cli", + event="turn.completed", + source="cli_event", + node_id=claim.node.node_id, + claude_session_id=captured_session_id, + ) + await update_ui(format_status("✅", "Complete"), force=True) + await complete_claim(captured_session_id) + elif ptype == "error": + error_msg = parsed.get("message", "Unknown error") + em = error_msg if isinstance(error_msg, str) else str(error_msg) + trace_event( + stage="claude_cli", + event="turn.failed", + source="cli_event", + node_id=claim.node.node_id, + claude_session_id=captured_session_id, + cli_error_message=em, + ) + if log_messaging_error_details: + logger.error("HANDLER: Error event received: {}", error_msg) + else: + logger.error( + "HANDLER: Error event received: message_chars={}", + text_len_hint(em), + ) + await update_ui(format_status("❌", "Error"), force=True) + await fail_claim(em, "Parent task failed") + + return last_status, had_transcript_events diff --git a/src/free_claude_code/messaging/node_runner.py b/src/free_claude_code/messaging/node_runner.py new file mode 100644 index 0000000..ad4bfa3 --- /dev/null +++ b/src/free_claude_code/messaging/node_runner.py @@ -0,0 +1,408 @@ +"""Run queued messaging nodes through a managed CLI session.""" + +import asyncio +from collections.abc import Callable + +from loguru import logger + +from free_claude_code.core.diagnostics import ( + format_user_error_preview, + safe_exception_message, +) +from free_claude_code.core.trace import trace_event + +from .event_parser import parse_cli_event +from .managed_protocols import ManagedClaudeSessionManagerProtocol +from .node_event_pipeline import handle_session_info_event, process_parsed_cli_event +from .platforms.ports import OutboundMessenger +from .safe_diagnostics import format_exception_for_log +from .session import SessionStore +from .transcript import RenderCtx, TranscriptBuffer +from .trees import CancellationReason, NodeClaim, TreeQueueManager, TreeSnapshot +from .ui_updates import ThrottledTranscriptEditor + + +class MessagingNodeRunner: + """Owns the lifecycle of one queued messaging node.""" + + def __init__( + self, + *, + platform_name: str, + outbound: OutboundMessenger, + cli_manager: ManagedClaudeSessionManagerProtocol, + session_store: SessionStore, + get_tree_queue: Callable[[], TreeQueueManager], + format_status: Callable[[str, str, str | None], str], + get_parse_mode: Callable[[], str | None], + get_render_ctx: Callable[[], RenderCtx], + get_limit_chars: Callable[[], int], + debug_platform_edits: bool = False, + debug_subagent_stack: bool = False, + log_raw_cli_diagnostics: bool = False, + log_messaging_error_details: bool = False, + ) -> None: + self.platform_name = platform_name + self.outbound = outbound + self.cli_manager = cli_manager + self.session_store = session_store + self._get_tree_queue = get_tree_queue + self._format_status = format_status + self._get_parse_mode = get_parse_mode + self._get_render_ctx = get_render_ctx + self._get_limit_chars = get_limit_chars + self._debug_platform_edits = debug_platform_edits + self._debug_subagent_stack = debug_subagent_stack + self._log_raw_cli_diagnostics = log_raw_cli_diagnostics + self._log_messaging_error_details = log_messaging_error_details + + def _create_transcript_and_render_ctx( + self, + ) -> tuple[TranscriptBuffer, RenderCtx]: + """Create transcript buffer and render context for node processing.""" + transcript = TranscriptBuffer( + show_tool_results=False, + debug_subagent_stack=self._debug_subagent_stack, + ) + return transcript, self._get_render_ctx() + + def _save_snapshot(self, snapshot: TreeSnapshot | None) -> None: + """Persist a snapshot returned by the active aggregate manager.""" + if snapshot is None: + return + self.session_store.save_tree_snapshot(snapshot) + + async def _record_session(self, claim: NodeClaim, session_id: str) -> None: + snapshot = await self._get_tree_queue().record_session(claim, session_id) + self._save_snapshot(snapshot) + + async def _complete_claim( + self, + claim: NodeClaim, + session_id: str | None, + ) -> None: + snapshot = await self._get_tree_queue().complete_claim(claim, session_id) + self._save_snapshot(snapshot) + + async def _fail_claim( + self, + claim: NodeClaim, + *, + propagate: bool, + child_status_text: str | None = None, + ) -> None: + result = await self._get_tree_queue().fail_claim( + claim, + propagate=propagate, + ) + self._save_snapshot(result.snapshot) + if child_status_text is None: + return + for child in result.affected: + if child.node_id == claim.node.node_id: + continue + self.outbound.fire_and_forget( + self.outbound.queue_edit_message( + child.scope.chat_id, + child.status_message_id, + self._format_status("❌", "Cancelled:", child_status_text), + parse_mode=self._get_parse_mode(), + ) + ) + + async def process_node( + self, + claim: NodeClaim, + ) -> None: + """Core task processor for a single CLI interaction.""" + node_id = claim.node.node_id + status_msg_id = claim.node.status_message_id + chat_id = claim.node.scope.chat_id + + with logger.contextualize(node_id=node_id, chat_id=chat_id): + await self._process_node_impl(claim, chat_id, status_msg_id) + + async def _process_node_impl( + self, + claim: NodeClaim, + chat_id: str, + status_msg_id: str, + ) -> None: + """Internal implementation of process_node with context bound.""" + node_id = claim.node.node_id + + transcript, render_ctx = self._create_transcript_and_render_ctx() + + had_transcript_events = False + non_exit_error: str | None = None + terminal_seen = False + captured_session_id = None + temp_session_id = None + last_status: str | None = None + + parent_session_id = claim.parent_session_id + platform_nm = self.platform_name + if parent_session_id: + trace_event( + stage="claude_cli", + event="claude_cli.fork.from_parent_session", + source=platform_nm, + chat_id=chat_id, + node_id=node_id, + parent_session_id=parent_session_id, + ) + + editor = ThrottledTranscriptEditor( + outbound=self.outbound, + parse_mode=self._get_parse_mode(), + get_limit_chars=self._get_limit_chars, + transcript=transcript, + render_ctx=render_ctx, + node_id=node_id, + chat_id=chat_id, + status_msg_id=status_msg_id, + debug_platform_edits=self._debug_platform_edits, + log_messaging_error_details=self._log_messaging_error_details, + ) + + async def update_ui(status: str | None = None, force: bool = False) -> None: + await editor.update(status, force=force) + + try: + try: + ( + cli_session, + session_or_temp_id, + is_new, + ) = await self.cli_manager.get_or_create_session( + session_id=parent_session_id + ) + if is_new: + temp_session_id = session_or_temp_id + else: + captured_session_id = session_or_temp_id + + sess_evt = ( + "claude_cli.session.pending_created" + if is_new + else "claude_cli.session.reused" + ) + trace_event( + stage="claude_cli", + event=sess_evt, + source=platform_nm, + chat_id=chat_id, + node_id=node_id, + status_message_id=status_msg_id, + session_handle=str(session_or_temp_id), + parent_resume_session_id=parent_session_id, + fork_requested=bool(parent_session_id), + ) + trace_event( + stage="claude_cli", + event="claude_cli.request.sent", + source=platform_nm, + chat_id=chat_id, + node_id=node_id, + prompt=claim.prompt, + fork_session_arg=bool(parent_session_id), + resume_session_arg=parent_session_id, + ) + except RuntimeError as e: + error_message = safe_exception_message(e) + transcript.apply({"type": "error", "message": error_message}) + await update_ui( + self._format_status("⏳", "Session limit reached", None), + force=True, + ) + await self._fail_claim( + claim, + propagate=False, + ) + trace_event( + stage="claude_cli", + event="claude_cli.session.limit_reached", + source=platform_nm, + chat_id=chat_id, + node_id=node_id, + ) + return + + async for event_data in cli_session.start_task( + claim.prompt, + session_id=parent_session_id, + fork_session=bool(parent_session_id), + ): + if not isinstance(event_data, dict): + logger.warning( + f"HANDLER: Non-dict event received: {type(event_data)}" + ) + continue + + ( + captured_session_id, + temp_session_id, + ) = await handle_session_info_event( + event_data, + claim, + captured_session_id, + temp_session_id, + cli_manager=self.cli_manager, + record_session=lambda session_id: self._record_session( + claim, session_id + ), + ) + if event_data.get("type") == "session_info": + continue + + parsed_list = parse_cli_event( + event_data, log_raw_cli=self._log_raw_cli_diagnostics + ) + + for parsed in parsed_list: + ptype = parsed.get("type") + if ( + ptype == "error" + and parsed.get("source") == "exit" + and non_exit_error is not None + ): + await self._fail_claim( + claim, + propagate=True, + child_status_text="Parent task failed", + ) + terminal_seen = True + continue + + propagate_failure = parsed.get("source") == "exit" + + async def fail_parsed_event( + error_message: str, + child_status: str, + propagate: bool = propagate_failure, + ) -> None: + await self._fail_claim( + claim, + propagate=propagate, + child_status_text=child_status, + ) + + ( + last_status, + had_transcript_events, + ) = await process_parsed_cli_event( + parsed, + transcript, + update_ui, + last_status, + had_transcript_events, + claim, + captured_session_id, + format_status=self._format_status, + complete_claim=lambda session_id: self._complete_claim( + claim, session_id + ), + fail_claim=fail_parsed_event, + log_messaging_error_details=self._log_messaging_error_details, + ) + if ptype == "error" and parsed.get("source") != "exit": + error_message = parsed.get("message", "Unknown error") + non_exit_error = ( + error_message + if isinstance(error_message, str) + else str(error_message) + ) + if (ptype == "error" and parsed.get("source") == "exit") or ( + ptype == "complete" and parsed.get("status") == "success" + ): + terminal_seen = True + + if non_exit_error is not None and not terminal_seen: + await self._fail_claim( + claim, + propagate=True, + child_status_text="Parent task failed", + ) + elif not terminal_seen: + error_message = "Claude CLI ended without a terminal event" + transcript.apply({"type": "error", "message": error_message}) + await update_ui( + self._format_status("💥", "Task Failed", None), + force=True, + ) + await self._fail_claim( + claim, + propagate=True, + child_status_text="Parent task failed", + ) + + except asyncio.CancelledError as exc: + trace_event( + stage="claude_cli", + event="turn.processor.cancelled", + source=platform_nm, + chat_id=chat_id, + node_id=node_id, + ) + logger.warning(f"HANDLER: Task cancelled for node {node_id}") + reason = exc.args[0] if exc.args else None + if reason is CancellationReason.STOP: + await update_ui(self._format_status("⏹", "Stopped.", None), force=True) + elif reason is not CancellationReason.CLEAR: + transcript.apply({"type": "error", "message": "Task was cancelled"}) + await update_ui( + self._format_status("❌", "Cancelled", None), force=True + ) + + await self._fail_claim( + claim, + propagate=False, + ) + except Exception as e: + trace_event( + stage="claude_cli", + event="turn.processor.exception", + source=platform_nm, + chat_id=chat_id, + node_id=node_id, + exc_type=type(e).__name__, + ) + logger.error( + "HANDLER: Task failed with exception: {}", + format_exception_for_log( + e, log_full_message=self._log_messaging_error_details + ), + ) + error_msg = format_user_error_preview(e) + transcript.apply({"type": "error", "message": error_msg}) + await update_ui(self._format_status("💥", "Task Failed", None), force=True) + await self._fail_claim( + claim, + propagate=True, + child_status_text="Parent task failed", + ) + finally: + trace_event( + stage="routing", + event="turn.processor.finished", + source=platform_nm, + chat_id=chat_id, + node_id=node_id, + claude_session_id=captured_session_id or temp_session_id, + ) + try: + if captured_session_id: + await self.cli_manager.remove_session(captured_session_id) + elif temp_session_id: + await self.cli_manager.remove_session(temp_session_id) + except Exception as e: + logger.debug( + "Failed to remove session for node {}: {}", + node_id, + format_exception_for_log( + e, log_full_message=self._log_messaging_error_details + ), + ) + + +__all__ = ["MessagingNodeRunner"] diff --git a/src/free_claude_code/messaging/platforms/__init__.py b/src/free_claude_code/messaging/platforms/__init__.py new file mode 100644 index 0000000..f7f302f --- /dev/null +++ b/src/free_claude_code/messaging/platforms/__init__.py @@ -0,0 +1,20 @@ +"""Messaging platform runtimes and ports.""" + +from .factory import MessagingPlatformOptions, create_messaging_components +from .ports import ( + MessagingPlatformComponents, + MessagingRuntime, + MessagingStartupNotice, + OutboundMessenger, + VoiceCancellation, +) + +__all__ = [ + "MessagingPlatformComponents", + "MessagingPlatformOptions", + "MessagingRuntime", + "MessagingStartupNotice", + "OutboundMessenger", + "VoiceCancellation", + "create_messaging_components", +] diff --git a/src/free_claude_code/messaging/platforms/discord.py b/src/free_claude_code/messaging/platforms/discord.py new file mode 100644 index 0000000..1a77b5e --- /dev/null +++ b/src/free_claude_code/messaging/platforms/discord.py @@ -0,0 +1,319 @@ +"""Discord messaging runtime.""" + +import asyncio +import contextlib +from collections.abc import Awaitable, Callable +from typing import Any + +from loguru import logger + +from free_claude_code.core.diagnostics import format_user_error_preview + +from ..limiter import MessagingRateLimiter +from ..models import IncomingMessage, MessageScope +from ..rendering.discord_markdown import format_status_discord +from ..voice import Transcriber, VoiceCancellationResult +from .discord_inbound import ( + discord_text_message_from_event, + discord_voice_request_from_event, + get_audio_attachment, + parse_allowed_channels, +) +from .discord_io import DiscordMessenger +from .ports import InboundMessageHandler +from .voice_flow import VoiceNoteFlow + +_discord_module: Any = None +try: + import discord as _discord_import + + _discord_module = _discord_import + DISCORD_AVAILABLE = True +except ImportError: + DISCORD_AVAILABLE = False + + +def _get_discord() -> Any: + """Return the discord module or raise a setup error.""" + if not DISCORD_AVAILABLE or _discord_module is None: + raise ImportError( + "discord.py is required. Install with: pip install discord.py" + ) + return _discord_module + + +if DISCORD_AVAILABLE and _discord_module is not None: + _discord = _discord_module + + class _DiscordClient(_discord.Client): + """Internal Discord client that forwards events to the runtime.""" + + def __init__( + self, + runtime: DiscordRuntime, + intents: _discord.Intents, + ) -> None: + super().__init__(intents=intents) + self._runtime = runtime + + async def on_ready(self) -> None: + self._runtime._mark_connected() + + async def on_message(self, message: Any) -> None: + await self._runtime._handle_client_message(message) +else: + _DiscordClient = None + + +class DiscordRuntime: + """Owns Discord SDK lifecycle and inbound event handoff.""" + + name = "discord" + + def __init__( + self, + bot_token: str | None = None, + allowed_channel_ids: str | None = None, + *, + limiter: MessagingRateLimiter, + transcriber: Transcriber | None, + log_raw_messaging_content: bool = False, + log_api_error_tracebacks: bool = False, + ) -> None: + if not DISCORD_AVAILABLE: + raise ImportError( + "discord.py is required. Install with: pip install discord.py" + ) + + self.bot_token = bot_token + self.allowed_channel_ids = parse_allowed_channels(allowed_channel_ids) + if not self.bot_token: + logger.warning("DISCORD_BOT_TOKEN not set") + + discord = _get_discord() + intents = discord.Intents.default() + intents.message_content = True + + assert _DiscordClient is not None + self._client = _DiscordClient(self, intents) + self._message_handler: InboundMessageHandler | None = None + self._connected = False + self._accepting_messages = False + self._ready = asyncio.Event() + self._inbound_tasks: set[asyncio.Task[Any]] = set() + self._limiter = limiter + self._start_task: asyncio.Task[None] | None = None + self.outbound = DiscordMessenger( + get_client=lambda: self._client, + get_discord=_get_discord, + limiter=limiter, + ) + self._voice_flow = VoiceNoteFlow( + transcriber=transcriber, + log_raw_messaging_content=log_raw_messaging_content, + log_api_error_tracebacks=log_api_error_tracebacks, + ) + self._log_raw_messaging_content = log_raw_messaging_content + self._log_api_error_tracebacks = log_api_error_tracebacks + + async def _handle_client_message(self, message: Any) -> None: + """Adapter entry point used by the internal Discord client.""" + if not self._accepting_messages: + return + task = asyncio.current_task() + if task is not None: + self._inbound_tasks.add(task) + try: + if self._accepting_messages: + await self._on_discord_message(message) + finally: + if task is not None: + self._inbound_tasks.discard(task) + + def _mark_connected(self) -> None: + """Publish Discord readiness while this runtime accepts ingress.""" + if not self._accepting_messages: + return + self._connected = True + self._ready.set() + logger.info("Discord platform connected") + + async def cancel_pending_voice( + self, scope: MessageScope, reply_id: str + ) -> VoiceCancellationResult | None: + """Cancel a pending voice transcription.""" + return await self._voice_flow.cancel_pending_voice(scope, reply_id) + + async def cancel_all_pending_voices( + self, + ) -> tuple[VoiceCancellationResult, ...]: + """Cancel every pending voice transcription and handoff.""" + return await self._voice_flow.cancel_all_pending_voices() + + async def cancel_pending_voices_in_scope( + self, + scope: MessageScope, + ) -> tuple[VoiceCancellationResult, ...]: + """Cancel pending voice transcriptions belonging to one chat.""" + return await self._voice_flow.cancel_pending_voices_in_scope(scope) + + async def _handle_voice_note( + self, message: Any, attachment: Any, channel_id: str + ) -> bool: + """Handle a Discord audio attachment.""" + return await self._voice_flow.handle( + discord_voice_request_from_event(message, attachment, channel_id), + message_handler=self._message_handler, + queue_send_message=self.outbound.queue_send_message, + queue_delete_messages=self.outbound.queue_delete_messages, + ) + + async def _on_discord_message(self, message: Any) -> None: + """Handle incoming Discord messages.""" + if message.author.bot: + return + + channel_id = str(message.channel.id) + if not self.allowed_channel_ids or channel_id not in self.allowed_channel_ids: + return + + if not message.content: + audio_att = get_audio_attachment(message) + if audio_att: + await self._handle_voice_note(message, audio_att, channel_id) + return + + incoming = discord_text_message_from_event( + message, + log_raw_messaging_content=self._log_raw_messaging_content, + ) + if self._message_handler is None: + return + + try: + await self._message_handler(incoming) + except Exception as e: + if self._log_api_error_tracebacks: + logger.error("Error handling message: {}", e) + else: + logger.error("Error handling message: exc_type={}", type(e).__name__) + with contextlib.suppress(Exception): + await self.outbound.send_message( + channel_id, + format_status_discord("Error:", format_user_error_preview(e)), + reply_to=str(message.id), + ) + + async def start(self) -> None: + """Initialize and connect to Discord.""" + if not self.bot_token: + raise ValueError("DISCORD_BOT_TOKEN is required") + + self._limiter.start() + self._accepting_messages = True + self._ready.clear() + + self._start_task = asyncio.create_task( + self._client.start(self.bot_token), + name="discord-client-start", + ) + self._start_task.add_done_callback(self._observe_client_exit) + ready_task = asyncio.create_task( + self._ready.wait(), + name="discord-client-ready", + ) + try: + done, _pending = await asyncio.wait( + (self._start_task, ready_task), + timeout=30.0, + return_when=asyncio.FIRST_COMPLETED, + ) + if not done: + raise RuntimeError("Discord client failed to connect within timeout") + if self._start_task in done: + await self._start_task + raise RuntimeError("Discord client stopped before becoming ready") + if self._start_task.done(): + await self._start_task + raise RuntimeError("Discord client stopped unexpectedly") + finally: + ready_task.cancel() + await asyncio.gather(ready_task, return_exceptions=True) + + logger.info("Discord platform started") + + def _observe_client_exit(self, task: asyncio.Task[None]) -> None: + """Observe the long-lived Discord client task and publish lost readiness.""" + if task.cancelled(): + exception: BaseException | None = None + else: + exception = task.exception() + + was_connected = self._connected + if not self._accepting_messages: + return + + self._connected = False + self._ready.clear() + if not was_connected: + return + + if exception is None: + logger.error("Discord client stopped unexpectedly") + elif self._log_api_error_tracebacks: + logger.error("Discord client stopped unexpectedly: {}", exception) + else: + logger.error( + "Discord client stopped unexpectedly: exc_type={}", + type(exception).__name__, + ) + + async def quiesce(self) -> None: + """Stop Discord ingress and drain active SDK handlers.""" + self._accepting_messages = False + try: + if not self._client.is_closed(): + await self._client.close() + finally: + try: + await self._drain_start_task() + finally: + try: + await self._drain_inbound_tasks() + finally: + self._connected = False + self._ready.clear() + + async def close(self) -> None: + """Close Discord delivery resources after ingress is quiescent.""" + try: + await self.outbound.close() + finally: + await self._limiter.shutdown() + logger.info("Discord platform closed") + + async def _drain_start_task(self) -> None: + task = self._start_task + if task is None: + return + if not task.done(): + task.cancel() + try: + await asyncio.gather(task, return_exceptions=True) + finally: + if task.done() and self._start_task is task: + self._start_task = None + + async def _drain_inbound_tasks(self) -> None: + tasks = tuple(self._inbound_tasks) + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + def on_message(self, handler: Callable[[IncomingMessage], Awaitable[None]]) -> None: + """Register the workflow callback for inbound messages.""" + self._message_handler = handler + + @property + def is_connected(self) -> bool: + """Return whether Discord startup completed.""" + return self._connected diff --git a/src/free_claude_code/messaging/platforms/discord_inbound.py b/src/free_claude_code/messaging/platforms/discord_inbound.py new file mode 100644 index 0000000..ba35838 --- /dev/null +++ b/src/free_claude_code/messaging/platforms/discord_inbound.py @@ -0,0 +1,112 @@ +"""Discord inbound event normalization.""" + +from typing import Any + +from loguru import logger + +from ..models import IncomingMessage +from ..rendering.discord_markdown import format_status_discord +from .voice_flow import ( + VoiceNoteRequest, + audio_suffix_from_metadata, + is_audio_metadata, +) + + +def parse_allowed_channels(raw: str | None) -> set[str]: + """Parse comma-separated Discord channel IDs.""" + if not raw or not raw.strip(): + return set() + return {s.strip() for s in raw.split(",") if s.strip()} + + +def get_audio_attachment(message: Any) -> Any | None: + """Return the first audio attachment from a Discord message.""" + for att in message.attachments: + if is_audio_metadata(att.filename, att.content_type): + return att + return None + + +def discord_text_message_from_event( + message: Any, + *, + log_raw_messaging_content: bool, +) -> IncomingMessage: + """Normalize a Discord message into an incoming text message.""" + channel_id = str(message.channel.id) + message_id = str(message.id) + reply_to = ( + str(message.reference.message_id) + if message.reference and message.reference.message_id + else None + ) + raw_content = message.content or "" + if log_raw_messaging_content: + text_preview = raw_content[:80] + if len(raw_content) > 80: + text_preview += "..." + logger.info( + "DISCORD_MSG: chat_id={} message_id={} reply_to={} text_preview={!r}", + channel_id, + message_id, + reply_to, + text_preview, + ) + else: + logger.info( + "DISCORD_MSG: chat_id={} message_id={} reply_to={} text_len={}", + channel_id, + message_id, + reply_to, + len(raw_content), + ) + + return IncomingMessage( + text=message.content, + chat_id=channel_id, + user_id=str(message.author.id), + message_id=message_id, + platform="discord", + reply_to_message_id=reply_to, + username=message.author.display_name, + raw_event=message, + ) + + +def discord_voice_request_from_event( + message: Any, + attachment: Any, + channel_id: str, +) -> VoiceNoteRequest: + """Normalize a Discord voice/audio attachment into a voice-note request.""" + message_id = str(message.id) + reply_to = ( + str(message.reference.message_id) + if message.reference and message.reference.message_id + else None + ) + + async def _download_to(tmp_path) -> None: + await attachment.save(str(tmp_path)) + + async def _reply_text(text: str) -> None: + await message.reply(text) + + return VoiceNoteRequest( + platform="discord", + chat_id=channel_id, + user_id=str(message.author.id), + message_id=message_id, + raw_event=message, + content_type=attachment.content_type or "audio/ogg", + temp_suffix=audio_suffix_from_metadata( + filename=attachment.filename, + content_type=attachment.content_type, + ), + status_text=format_status_discord("Transcribing voice note..."), + reply_to_message_id=reply_to, + username=message.author.display_name, + download_to=_download_to, + reply_text=_reply_text, + ) diff --git a/src/free_claude_code/messaging/platforms/discord_io.py b/src/free_claude_code/messaging/platforms/discord_io.py new file mode 100644 index 0000000..ae8dfd4 --- /dev/null +++ b/src/free_claude_code/messaging/platforms/discord_io.py @@ -0,0 +1,167 @@ +"""Discord outbound delivery.""" + +from collections.abc import Awaitable, Callable +from typing import Any, cast + +from ..limiter import MessagingRateLimiter +from .outbox import PlatformOutbox + +DISCORD_MESSAGE_LIMIT = 2000 + +ClientGetter = Callable[[], Any] +DiscordGetter = Callable[[], Any] + + +def truncate_discord_message(text: str, limit: int = DISCORD_MESSAGE_LIMIT) -> str: + """Return text that fits Discord's message limit.""" + if len(text) <= limit: + return text + return text[: limit - 3] + "..." + + +class DiscordMessenger: + """Owns Discord sends, edits, deletes, and queued delivery.""" + + def __init__( + self, + *, + get_client: ClientGetter, + get_discord: DiscordGetter, + limiter: MessagingRateLimiter, + ) -> None: + self._get_client = get_client + self._get_discord = get_discord + self._outbox = PlatformOutbox( + limiter=limiter, + send=self.send_message, + edit=self.edit_message, + delete_many=self.delete_messages, + ) + + async def send_message( + self, + chat_id: str, + text: str, + reply_to: str | None = None, + parse_mode: str | None = None, + message_thread_id: str | None = None, + ) -> str: + """Send a Discord message immediately.""" + client = self._get_client() + channel = client.get_channel(int(chat_id)) + if not channel or not hasattr(channel, "send"): + raise RuntimeError(f"Channel {chat_id} not found") + + text = truncate_discord_message(text) + channel = cast(Any, channel) + + if reply_to: + discord = self._get_discord() + ref = discord.MessageReference( + message_id=int(reply_to), + channel_id=int(chat_id), + ) + msg = await channel.send(content=text, reference=ref) + else: + msg = await channel.send(content=text) + + return str(msg.id) + + async def edit_message( + self, + chat_id: str, + message_id: str, + text: str, + parse_mode: str | None = None, + ) -> None: + """Edit a Discord message immediately.""" + client = self._get_client() + channel = client.get_channel(int(chat_id)) + if not channel or not hasattr(channel, "fetch_message"): + raise RuntimeError(f"Channel {chat_id} not found") + + discord = self._get_discord() + channel = cast(Any, channel) + try: + msg = await channel.fetch_message(int(message_id)) + except discord.NotFound: + return + + await msg.edit(content=truncate_discord_message(text)) + + async def delete_message(self, chat_id: str, message_id: str) -> None: + """Delete a Discord message immediately.""" + client = self._get_client() + channel = client.get_channel(int(chat_id)) + if not channel or not hasattr(channel, "fetch_message"): + return + + discord = self._get_discord() + channel = cast(Any, channel) + try: + msg = await channel.fetch_message(int(message_id)) + await msg.delete() + except discord.NotFound, discord.Forbidden: + pass + + async def delete_messages(self, chat_id: str, message_ids: list[str]) -> None: + """Delete multiple Discord messages best-effort.""" + for mid in message_ids: + await self.delete_message(chat_id, mid) + + async def queue_send_message( + self, + chat_id: str, + text: str, + reply_to: str | None = None, + parse_mode: str | None = None, + fire_and_forget: bool = True, + message_thread_id: str | None = None, + ) -> str | None: + """Queue a Discord send.""" + return await self._outbox.queue_send_message( + chat_id, + text, + reply_to, + parse_mode, + fire_and_forget, + message_thread_id, + ) + + async def queue_edit_message( + self, + chat_id: str, + message_id: str, + text: str, + parse_mode: str | None = None, + fire_and_forget: bool = True, + ) -> None: + """Queue a Discord edit.""" + await self._outbox.queue_edit_message( + chat_id, + message_id, + text, + parse_mode, + fire_and_forget, + ) + + async def queue_delete_messages( + self, + chat_id: str, + message_ids: list[str], + fire_and_forget: bool = True, + ) -> None: + """Queue a Discord bulk delete.""" + await self._outbox.queue_delete_messages( + chat_id, + message_ids, + fire_and_forget, + ) + + def fire_and_forget(self, task: Awaitable[Any]) -> None: + """Execute a coroutine without awaiting it.""" + self._outbox.fire_and_forget(task) + + async def close(self) -> None: + """Cancel outstanding outbound work.""" + await self._outbox.close() diff --git a/src/free_claude_code/messaging/platforms/factory.py b/src/free_claude_code/messaging/platforms/factory.py new file mode 100644 index 0000000..0b678bb --- /dev/null +++ b/src/free_claude_code/messaging/platforms/factory.py @@ -0,0 +1,109 @@ +"""Messaging platform component factory.""" + +from dataclasses import dataclass + +from loguru import logger + +from ..limiter import MessagingRateLimiter +from ..voice import Transcriber +from .ports import MessagingPlatformComponents, MessagingStartupNotice + + +@dataclass(frozen=True, slots=True) +class MessagingPlatformOptions: + """Typed wiring from app settings into messaging platform runtimes.""" + + telegram_bot_token: str | None = None + allowed_telegram_user_id: str | None = None + telegram_proxy_url: str = "" + discord_bot_token: str | None = None + allowed_discord_channels: str | None = None + transcriber: Transcriber | None = None + messaging_rate_limit: int = 1 + messaging_rate_window: float = 1.0 + log_raw_messaging_content: bool = False + log_messaging_error_details: bool = False + log_api_error_tracebacks: bool = False + + +def create_messaging_components( + platform_type: str, + options: MessagingPlatformOptions | None = None, +) -> MessagingPlatformComponents | None: + """Create runtime/outbound components for the configured messaging platform.""" + opts = options or MessagingPlatformOptions() + if platform_type == "none": + logger.info("Messaging platform disabled by configuration") + return None + + if platform_type == "telegram": + bot_token = opts.telegram_bot_token + if not bot_token: + logger.info("No Telegram bot token configured, skipping platform setup") + return None + + from .telegram import TelegramRuntime + + limiter = MessagingRateLimiter( + rate_limit=opts.messaging_rate_limit, + rate_window=opts.messaging_rate_window, + log_error_details=opts.log_messaging_error_details, + ) + runtime = TelegramRuntime( + bot_token=bot_token, + allowed_user_id=opts.allowed_telegram_user_id, + telegram_proxy_url=opts.telegram_proxy_url, + limiter=limiter, + transcriber=opts.transcriber, + log_raw_messaging_content=opts.log_raw_messaging_content, + log_api_error_tracebacks=opts.log_api_error_tracebacks, + ) + startup_notice = ( + MessagingStartupNotice( + chat_id=opts.allowed_telegram_user_id, + transport_label="Bot API", + ) + if opts.allowed_telegram_user_id + else None + ) + return MessagingPlatformComponents( + name=runtime.name, + runtime=runtime, + outbound=runtime.outbound, + voice_cancellation=runtime, + startup_notice=startup_notice, + ) + + if platform_type == "discord": + bot_token = opts.discord_bot_token + if not bot_token: + logger.info("No Discord bot token configured, skipping platform setup") + return None + + from .discord import DiscordRuntime + + limiter = MessagingRateLimiter( + rate_limit=opts.messaging_rate_limit, + rate_window=opts.messaging_rate_window, + log_error_details=opts.log_messaging_error_details, + ) + runtime = DiscordRuntime( + bot_token=bot_token, + allowed_channel_ids=opts.allowed_discord_channels, + limiter=limiter, + transcriber=opts.transcriber, + log_raw_messaging_content=opts.log_raw_messaging_content, + log_api_error_tracebacks=opts.log_api_error_tracebacks, + ) + return MessagingPlatformComponents( + name=runtime.name, + runtime=runtime, + outbound=runtime.outbound, + voice_cancellation=runtime, + ) + + logger.warning( + "Unknown messaging platform: '{}'. Supported: 'none', 'telegram', 'discord'", + platform_type, + ) + return None diff --git a/src/free_claude_code/messaging/platforms/outbox.py b/src/free_claude_code/messaging/platforms/outbox.py new file mode 100644 index 0000000..0b9c51d --- /dev/null +++ b/src/free_claude_code/messaging/platforms/outbox.py @@ -0,0 +1,143 @@ +"""Shared queued delivery helper for messaging platforms.""" + +import asyncio +import hashlib +from collections.abc import Awaitable, Callable +from typing import Any, cast + +from loguru import logger + +from ..limiter import MessagingRateLimiter + +SendOperation = Callable[ + [str, str, str | None, str | None, str | None], + Awaitable[str], +] +EditOperation = Callable[[str, str, str, str | None], Awaitable[None]] +DeleteManyOperation = Callable[[str, list[str]], Awaitable[None]] + + +class PlatformOutbox: + """Own queueing, deduplication, and fire-and-forget delivery policy.""" + + def __init__( + self, + *, + limiter: MessagingRateLimiter, + send: SendOperation, + edit: EditOperation, + delete_many: DeleteManyOperation, + ) -> None: + self._limiter = limiter + self._send = send + self._edit = edit + self._delete_many = delete_many + self._background_tasks: set[asyncio.Future[Any]] = set() + self._closed = False + + async def queue_send_message( + self, + chat_id: str, + text: str, + reply_to: str | None = None, + parse_mode: str | None = None, + fire_and_forget: bool = True, + message_thread_id: str | None = None, + ) -> str | None: + """Queue or immediately send a platform message.""" + self._require_open() + + async def _send() -> str: + return await self._send( + chat_id, + text, + reply_to, + parse_mode, + message_thread_id, + ) + + if fire_and_forget: + self._limiter.fire_and_forget(_send) + return None + return cast(str | None, await self._limiter.enqueue(_send)) + + async def queue_edit_message( + self, + chat_id: str, + message_id: str, + text: str, + parse_mode: str | None = None, + fire_and_forget: bool = True, + ) -> None: + """Queue or immediately edit a platform message.""" + self._require_open() + + async def _edit() -> None: + await self._edit(chat_id, message_id, text, parse_mode) + + dedup_key = f"edit:{chat_id}:{message_id}" + if fire_and_forget: + self._limiter.fire_and_forget(_edit, dedup_key=dedup_key) + else: + await self._limiter.enqueue(_edit, dedup_key=dedup_key) + + async def queue_delete_messages( + self, + chat_id: str, + message_ids: list[str], + fire_and_forget: bool = True, + ) -> None: + """Queue or immediately bulk-delete platform messages.""" + self._require_open() + ids_snapshot = tuple(str(message_id) for message_id in message_ids) + if not ids_snapshot: + return + + async def _delete_many() -> None: + await self._delete_many(chat_id, list(ids_snapshot)) + + digest = hashlib.sha256("\x1f".join(ids_snapshot).encode()).hexdigest()[:16] + dedup_key = f"del_bulk:{chat_id}:{digest}" + if fire_and_forget: + self._limiter.fire_and_forget(_delete_many, dedup_key=dedup_key) + else: + await self._limiter.enqueue(_delete_many, dedup_key=dedup_key) + + def fire_and_forget(self, task: Awaitable[Any]) -> None: + """Run and retain arbitrary outbound work until completion or shutdown.""" + future = asyncio.ensure_future(task) + if self._closed: + future.cancel() + raise RuntimeError("Platform outbox is closed.") + self._background_tasks.add(future) + future.add_done_callback(self._complete_background_task) + + async def close(self) -> None: + """Cancel and await arbitrary outbound work owned by this outbox.""" + if not self._closed: + self._closed = True + tasks = tuple(self._background_tasks) + for task in tasks: + task.cancel() + try: + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + finally: + self._background_tasks.difference_update( + task for task in tasks if task.done() + ) + + def _complete_background_task(self, task: asyncio.Future[Any]) -> None: + self._background_tasks.discard(task) + if task.cancelled(): + return + error = task.exception() + if error is not None: + logger.error( + "Outbound background task failed: exc_type={}", + type(error).__name__, + ) + + def _require_open(self) -> None: + if self._closed: + raise RuntimeError("Platform outbox is closed.") diff --git a/src/free_claude_code/messaging/platforms/ports.py b/src/free_claude_code/messaging/platforms/ports.py new file mode 100644 index 0000000..4e5c9af --- /dev/null +++ b/src/free_claude_code/messaging/platforms/ports.py @@ -0,0 +1,99 @@ +"""Messaging platform ports used by the customer-facing workflow.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Protocol, runtime_checkable + +from ..models import IncomingMessage, MessageScope +from ..voice import VoiceCancellationResult + +InboundMessageHandler = Callable[[IncomingMessage], Awaitable[None]] + + +@runtime_checkable +class MessagingRuntime(Protocol): + """Owns ingress and delivery lifecycle for one messaging platform.""" + + @property + def name(self) -> str: ... + + async def start(self) -> None: ... + + async def quiesce(self) -> None: ... + + async def close(self) -> None: ... + + def on_message(self, handler: InboundMessageHandler) -> None: ... + + @property + def is_connected(self) -> bool: ... + + +@runtime_checkable +class OutboundMessenger(Protocol): + """Owns queued outbound platform delivery.""" + + async def queue_send_message( + self, + chat_id: str, + text: str, + reply_to: str | None = None, + parse_mode: str | None = None, + fire_and_forget: bool = True, + message_thread_id: str | None = None, + ) -> str | None: ... + + async def queue_edit_message( + self, + chat_id: str, + message_id: str, + text: str, + parse_mode: str | None = None, + fire_and_forget: bool = True, + ) -> None: ... + + async def queue_delete_messages( + self, + chat_id: str, + message_ids: list[str], + fire_and_forget: bool = True, + ) -> None: ... + + def fire_and_forget(self, task: Awaitable[Any]) -> None: ... + + +@dataclass(frozen=True, slots=True) +class MessagingStartupNotice: + """One clearable customer notice declared by a platform composition.""" + + chat_id: str + transport_label: str + + +@runtime_checkable +class VoiceCancellation(Protocol): + """Optional voice-note cancellation boundary used by stop/clear flows.""" + + async def cancel_pending_voice( + self, scope: MessageScope, reply_id: str + ) -> VoiceCancellationResult | None: ... + + async def cancel_all_pending_voices( + self, + ) -> tuple[VoiceCancellationResult, ...]: ... + + async def cancel_pending_voices_in_scope( + self, + scope: MessageScope, + ) -> tuple[VoiceCancellationResult, ...]: ... + + +@dataclass(frozen=True, slots=True) +class MessagingPlatformComponents: + """Runtime/outbound bundle for one configured messaging platform.""" + + name: str + runtime: MessagingRuntime + outbound: OutboundMessenger + voice_cancellation: VoiceCancellation | None = None + startup_notice: MessagingStartupNotice | None = None diff --git a/src/free_claude_code/messaging/platforms/telegram.py b/src/free_claude_code/messaging/platforms/telegram.py new file mode 100644 index 0000000..4556b7c --- /dev/null +++ b/src/free_claude_code/messaging/platforms/telegram.py @@ -0,0 +1,300 @@ +"""Telegram messaging runtime.""" + +import asyncio +import contextlib +import os +from collections.abc import Awaitable, Callable + +# Opt-in to future behavior for python-telegram-bot (retry_after as timedelta). +os.environ["PTB_TIMEDELTA"] = "1" + +from loguru import logger + +from free_claude_code.core.diagnostics import format_user_error_preview + +from ..limiter import MessagingRateLimiter +from ..models import IncomingMessage, MessageScope +from ..rendering.telegram_markdown import escape_md_v2 +from ..voice import Transcriber, VoiceCancellationResult +from .ports import InboundMessageHandler +from .telegram_inbound import ( + telegram_text_message_from_update, + telegram_voice_request_from_update, +) +from .telegram_io import TelegramMessenger +from .voice_flow import VoiceNoteFlow + +try: + from telegram import Update + from telegram.ext import ( + Application, + CommandHandler, + ContextTypes, + MessageHandler, + filters, + ) + from telegram.request import HTTPXRequest + + TELEGRAM_AVAILABLE = True +except ImportError: + TELEGRAM_AVAILABLE = False + + +class TelegramRuntime: + """Owns Telegram SDK lifecycle and inbound event handoff.""" + + name = "telegram" + + def __init__( + self, + bot_token: str | None = None, + allowed_user_id: str | None = None, + *, + telegram_proxy_url: str = "", + limiter: MessagingRateLimiter, + transcriber: Transcriber | None, + log_raw_messaging_content: bool = False, + log_api_error_tracebacks: bool = False, + ) -> None: + if not TELEGRAM_AVAILABLE: + raise ImportError( + "python-telegram-bot is required. Install with: pip install python-telegram-bot" + ) + + self.bot_token = bot_token + self.allowed_user_id = allowed_user_id + self.telegram_proxy_url = telegram_proxy_url.strip() + if not self.bot_token: + logger.warning("TELEGRAM_BOT_TOKEN not set") + + self._application: Application | None = None + self._message_handler: InboundMessageHandler | None = None + self._connected = False + self._limiter = limiter + self.outbound = TelegramMessenger( + get_application=lambda: self._application, + limiter=limiter, + ) + self._voice_flow = VoiceNoteFlow( + transcriber=transcriber, + log_raw_messaging_content=log_raw_messaging_content, + log_api_error_tracebacks=log_api_error_tracebacks, + ) + self._log_raw_messaging_content = log_raw_messaging_content + self._log_api_error_tracebacks = log_api_error_tracebacks + + async def cancel_pending_voice( + self, scope: MessageScope, reply_id: str + ) -> VoiceCancellationResult | None: + """Cancel a pending voice transcription.""" + return await self._voice_flow.cancel_pending_voice(scope, reply_id) + + async def cancel_all_pending_voices( + self, + ) -> tuple[VoiceCancellationResult, ...]: + """Cancel every pending voice transcription and handoff.""" + return await self._voice_flow.cancel_all_pending_voices() + + async def cancel_pending_voices_in_scope( + self, + scope: MessageScope, + ) -> tuple[VoiceCancellationResult, ...]: + """Cancel pending voice transcriptions belonging to one chat.""" + return await self._voice_flow.cancel_pending_voices_in_scope(scope) + + async def start(self) -> None: + """Initialize and connect to Telegram.""" + if not self.bot_token: + raise ValueError("TELEGRAM_BOT_TOKEN is required") + + if self.telegram_proxy_url: + request = HTTPXRequest( + connection_pool_size=8, + connect_timeout=30.0, + read_timeout=30.0, + proxy=self.telegram_proxy_url, + ) + update_request = HTTPXRequest( + connection_pool_size=8, + connect_timeout=30.0, + read_timeout=30.0, + proxy=self.telegram_proxy_url, + ) + builder = ( + Application.builder() + .token(self.bot_token) + .request(request) + .get_updates_request(update_request) + ) + else: + request = HTTPXRequest( + connection_pool_size=8, connect_timeout=30.0, read_timeout=30.0 + ) + builder = Application.builder().token(self.bot_token).request(request) + application = builder.build() + self._application = application + + application.add_handler( + MessageHandler(filters.TEXT & (~filters.COMMAND), self._on_telegram_message) + ) + application.add_handler(CommandHandler("start", self._on_start_command)) + application.add_handler( + MessageHandler(filters.COMMAND, self._on_telegram_message) + ) + application.add_handler(MessageHandler(filters.VOICE, self._on_telegram_voice)) + + await self._retry_connection_step( + application.initialize, + step="initialization", + ) + await application.start() + self._limiter.start() + updater = application.updater + if updater is not None: + await self._retry_connection_step( + lambda: updater.start_polling(drop_pending_updates=False), + step="polling", + ) + self._connected = True + + logger.info("Telegram platform started (Bot API)") + + async def _retry_connection_step( + self, + operation: Callable[[], Awaitable[object]], + *, + step: str, + ) -> None: + """Retry one independently repeatable Telegram connection step.""" + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + try: + await operation() + return + except Exception as exc: + if attempt == max_attempts: + logger.error( + "Telegram {} failed after {} attempts", + step, + max_attempts, + ) + raise + wait_time = 2 * attempt + if self._log_api_error_tracebacks: + logger.warning( + "Telegram {} failed (attempt {}/{}): {}. Retrying in {}s...", + step, + attempt, + max_attempts, + exc, + wait_time, + ) + else: + logger.warning( + "Telegram {} failed (attempt {}/{}): exc_type={}. Retrying in {}s...", + step, + attempt, + max_attempts, + type(exc).__name__, + wait_time, + ) + await asyncio.sleep(wait_time) + + async def quiesce(self) -> None: + """Stop Telegram ingress after draining active SDK handlers.""" + application = self._application + updater = application.updater if application is not None else None + try: + if updater is not None and updater.running: + await updater.stop() + finally: + try: + if application is not None and application.running: + await application.stop() + finally: + self._connected = False + + async def close(self) -> None: + """Close Telegram delivery and initialized SDK resources.""" + application = self._application + try: + await self.outbound.close() + finally: + try: + await self._limiter.shutdown() + finally: + try: + if application is not None: + await application.shutdown() + finally: + logger.info("Telegram platform closed") + + def on_message(self, handler: Callable[[IncomingMessage], Awaitable[None]]) -> None: + """Register the workflow callback for inbound messages.""" + self._message_handler = handler + + @property + def is_connected(self) -> bool: + """Return whether Telegram startup completed.""" + return self._connected + + async def _on_start_command( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ) -> None: + if update.message: + await update.message.reply_text("👋 Hello! I am the Claude Code Proxy Bot.") + await self._on_telegram_message(update, context) + + async def _on_telegram_message( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ) -> None: + incoming = telegram_text_message_from_update( + update, + allowed_user_id=self.allowed_user_id, + log_raw_messaging_content=self._log_raw_messaging_content, + ) + if incoming is None or self._message_handler is None: + return + + try: + await self._message_handler(incoming) + except Exception as e: + if self._log_api_error_tracebacks: + logger.error("Error handling message: {}", e) + else: + logger.error("Error handling message: exc_type={}", type(e).__name__) + with contextlib.suppress(Exception): + await self.outbound.send_message( + incoming.chat_id, + f"❌ *{escape_md_v2('Error:')}* {escape_md_v2(format_user_error_preview(e))}", + reply_to=incoming.message_id, + message_thread_id=incoming.message_thread_id, + parse_mode="MarkdownV2", + ) + + async def _on_telegram_voice( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ) -> None: + message = update.message + + async def _reply_text(text: str) -> None: + if message is not None: + await message.reply_text(text) + + if await self._voice_flow.reply_if_disabled(_reply_text): + return + + request = telegram_voice_request_from_update( + update, + context, + allowed_user_id=self.allowed_user_id, + ) + if request is None: + return + + await self._voice_flow.handle( + request, + message_handler=self._message_handler, + queue_send_message=self.outbound.queue_send_message, + queue_delete_messages=self.outbound.queue_delete_messages, + ) diff --git a/src/free_claude_code/messaging/platforms/telegram_inbound.py b/src/free_claude_code/messaging/platforms/telegram_inbound.py new file mode 100644 index 0000000..3311d2d --- /dev/null +++ b/src/free_claude_code/messaging/platforms/telegram_inbound.py @@ -0,0 +1,133 @@ +"""Telegram inbound event normalization.""" + +from loguru import logger +from telegram import Update +from telegram.ext import ContextTypes + +from ..models import IncomingMessage +from ..rendering.telegram_markdown import format_status +from .voice_flow import VoiceNoteRequest, audio_suffix_from_metadata + + +def telegram_text_message_from_update( + update: Update, + *, + allowed_user_id: str | None, + log_raw_messaging_content: bool, +) -> IncomingMessage | None: + """Normalize a Telegram text update into an incoming message.""" + if ( + not update.message + or not update.message.text + or not update.effective_user + or not update.effective_chat + ): + return None + + user_id = str(update.effective_user.id) + chat_id = str(update.effective_chat.id) + if allowed_user_id and user_id != str(allowed_user_id).strip(): + logger.warning("Unauthorized access attempt from {}", user_id) + return None + + message = update.message + message_id = str(message.message_id) + reply_to = ( + str(message.reply_to_message.message_id) if message.reply_to_message else None + ) + thread_id = ( + str(message.message_thread_id) + if getattr(message, "message_thread_id", None) is not None + else None + ) + raw_text = message.text or "" + if log_raw_messaging_content: + text_preview = raw_text[:80] + if len(raw_text) > 80: + text_preview += "..." + logger.info( + "TELEGRAM_MSG: chat_id={} message_id={} reply_to={} text_preview={!r}", + chat_id, + message_id, + reply_to, + text_preview, + ) + else: + logger.info( + "TELEGRAM_MSG: chat_id={} message_id={} reply_to={} text_len={}", + chat_id, + message_id, + reply_to, + len(raw_text), + ) + + return IncomingMessage( + text=raw_text, + chat_id=chat_id, + user_id=user_id, + message_id=message_id, + platform="telegram", + reply_to_message_id=reply_to, + message_thread_id=thread_id, + raw_event=update, + ) + + +def telegram_voice_request_from_update( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + *, + allowed_user_id: str | None, +) -> VoiceNoteRequest | None: + """Normalize a Telegram voice update into a voice-note request.""" + message = update.message + effective_user = update.effective_user + effective_chat = update.effective_chat + if ( + message is None + or message.voice is None + or effective_user is None + or effective_chat is None + ): + return None + + user_id = str(effective_user.id) + if allowed_user_id and user_id != str(allowed_user_id).strip(): + logger.warning("Unauthorized voice access attempt from {}", user_id) + return None + + voice = message.voice + chat_id = str(effective_chat.id) + message_id = str(message.message_id) + thread_id = ( + str(message.message_thread_id) + if getattr(message, "message_thread_id", None) is not None + else None + ) + reply_to = ( + str(message.reply_to_message.message_id) if message.reply_to_message else None + ) + + async def _download_to(tmp_path) -> None: + tg_file = await context.bot.get_file(voice.file_id) + await tg_file.download_to_drive(custom_path=str(tmp_path)) + + async def _reply_text(text: str) -> None: + await message.reply_text(text) + + return VoiceNoteRequest( + platform="telegram", + chat_id=chat_id, + user_id=user_id, + message_id=message_id, + raw_event=update, + content_type=voice.mime_type or "audio/ogg", + temp_suffix=audio_suffix_from_metadata(content_type=voice.mime_type), + status_text=format_status("⏳", "Transcribing voice note..."), + status_parse_mode="MarkdownV2", + message_thread_id=thread_id, + reply_to_message_id=reply_to, + username=None, + download_to=_download_to, + reply_text=_reply_text, + ) diff --git a/src/free_claude_code/messaging/platforms/telegram_io.py b/src/free_claude_code/messaging/platforms/telegram_io.py new file mode 100644 index 0000000..dff1fbb --- /dev/null +++ b/src/free_claude_code/messaging/platforms/telegram_io.py @@ -0,0 +1,287 @@ +"""Telegram outbound delivery.""" + +import asyncio +from collections.abc import Awaitable, Callable +from datetime import timedelta +from typing import Any + +from loguru import logger + +from ..limiter import MessagingRateLimiter +from .outbox import PlatformOutbox + +TELEGRAM_DELETE_MESSAGES_BATCH_SIZE = 100 + +TelegramNetworkError: type[BaseException] +TelegramRetryAfter: type[BaseException] +TelegramBaseError: type[BaseException] +try: + from telegram.error import ( + NetworkError as _TelegramNetworkError, + ) + from telegram.error import ( + RetryAfter as _TelegramRetryAfter, + ) + from telegram.error import ( + TelegramError as _TelegramBaseError, + ) + + TelegramNetworkError = _TelegramNetworkError + TelegramRetryAfter = _TelegramRetryAfter + TelegramBaseError = _TelegramBaseError +except ImportError: + TelegramNetworkError = TimeoutError + TelegramRetryAfter = TimeoutError + TelegramBaseError = Exception + +ApplicationGetter = Callable[[], Any | None] + + +class TelegramMessenger: + """Owns Telegram sends, edits, deletes, and queued delivery.""" + + def __init__( + self, + *, + get_application: ApplicationGetter, + limiter: MessagingRateLimiter, + ) -> None: + self._get_application = get_application + self._outbox = PlatformOutbox( + limiter=limiter, + send=self.send_message, + edit=self.edit_message, + delete_many=self.delete_messages, + ) + + async def _with_retry( + self, + func: Callable[..., Awaitable[Any]], + *args: Any, + suppress_known_message_errors: bool = True, + **kwargs: Any, + ) -> Any: + """Execute a Telegram API call with the platform retry policy.""" + max_retries = 3 + for attempt in range(max_retries): + try: + return await func(*args, **kwargs) + except (TimeoutError, TelegramNetworkError) as e: + if "Message is not modified" in str(e): + if suppress_known_message_errors: + return None + raise + if attempt < max_retries - 1: + wait_time = 2**attempt + logger.warning( + "Telegram API network error (attempt {}/{}): {}. " + "Retrying in {}s...", + attempt + 1, + max_retries, + e, + wait_time, + ) + await asyncio.sleep(wait_time) + else: + logger.error( + "Telegram API failed after {} attempts: {}", + max_retries, + e, + ) + raise + except TelegramRetryAfter as e: + retry_after = getattr(e, "retry_after", 0) + wait_secs = ( + retry_after.total_seconds() + if isinstance(retry_after, timedelta) + else float(retry_after) + ) + logger.warning("Rate limited by Telegram, waiting {}s...", wait_secs) + await asyncio.sleep(wait_secs) + return await func(*args, **kwargs) + except TelegramBaseError as e: + err_lower = str(e).lower() + if "message is not modified" in err_lower: + if suppress_known_message_errors: + return None + raise + if any( + x in err_lower + for x in [ + "message to edit not found", + "message to delete not found", + "message can't be deleted", + "message can't be edited", + "not enough rights to delete", + ] + ): + if suppress_known_message_errors: + return None + raise + if "Can't parse entities" in str(e) and kwargs.get("parse_mode"): + logger.warning("Markdown failed, retrying without parse_mode") + kwargs["parse_mode"] = None + return await func(*args, **kwargs) + raise + return None + + async def send_message( + self, + chat_id: str, + text: str, + reply_to: str | None = None, + parse_mode: str | None = "MarkdownV2", + message_thread_id: str | None = None, + ) -> str: + """Send a Telegram message immediately.""" + app = self._get_application() + if not app or not app.bot: + raise RuntimeError("Telegram application or bot not initialized") + + async def _do_send(parse_mode: str | None = parse_mode) -> str: + kwargs: dict[str, Any] = { + "chat_id": chat_id, + "text": text, + "reply_to_message_id": int(reply_to) if reply_to else None, + "parse_mode": parse_mode, + } + if message_thread_id is not None: + kwargs["message_thread_id"] = int(message_thread_id) + msg = await app.bot.send_message(**kwargs) + return str(msg.message_id) + + return await self._with_retry(_do_send, parse_mode=parse_mode) + + async def edit_message( + self, + chat_id: str, + message_id: str, + text: str, + parse_mode: str | None = "MarkdownV2", + ) -> None: + """Edit a Telegram message immediately.""" + app = self._get_application() + if not app or not app.bot: + raise RuntimeError("Telegram application or bot not initialized") + + async def _do_edit(parse_mode: str | None = parse_mode) -> None: + await app.bot.edit_message_text( + chat_id=chat_id, + message_id=int(message_id), + text=text, + parse_mode=parse_mode, + ) + + await self._with_retry(_do_edit, parse_mode=parse_mode) + + async def delete_message(self, chat_id: str, message_id: str) -> None: + """Delete a Telegram message immediately.""" + app = self._get_application() + if not app or not app.bot: + raise RuntimeError("Telegram application or bot not initialized") + + async def _do_delete() -> None: + await app.bot.delete_message(chat_id=chat_id, message_id=int(message_id)) + + await self._with_retry(_do_delete) + + async def delete_messages(self, chat_id: str, message_ids: list[str]) -> None: + """Delete multiple Telegram messages best-effort.""" + if not message_ids: + return + app = self._get_application() + if not app or not app.bot: + raise RuntimeError("Telegram application or bot not initialized") + + bot = app.bot + mids: list[int] = [] + for mid in message_ids: + try: + mids.append(int(mid)) + except Exception: + continue + if not mids: + return + + if hasattr(bot, "delete_messages"): + for start in range(0, len(mids), TELEGRAM_DELETE_MESSAGES_BATCH_SIZE): + chunk = mids[start : start + TELEGRAM_DELETE_MESSAGES_BATCH_SIZE] + chunk_snapshot = tuple(chunk) + + async def _do_bulk(ids: tuple[int, ...] = chunk_snapshot) -> None: + await bot.delete_messages(chat_id=chat_id, message_ids=list(ids)) + + try: + await self._with_retry( + _do_bulk, + suppress_known_message_errors=False, + ) + except Exception as e: + logger.debug( + "Telegram bulk delete failed for chat {}: {}; falling back", + chat_id, + type(e).__name__, + ) + for mid in chunk: + await self.delete_message(chat_id, str(mid)) + return + + for mid in mids: + await self.delete_message(chat_id, str(mid)) + + async def queue_send_message( + self, + chat_id: str, + text: str, + reply_to: str | None = None, + parse_mode: str | None = "MarkdownV2", + fire_and_forget: bool = True, + message_thread_id: str | None = None, + ) -> str | None: + """Queue a Telegram send.""" + return await self._outbox.queue_send_message( + chat_id, + text, + reply_to, + parse_mode, + fire_and_forget, + message_thread_id, + ) + + async def queue_edit_message( + self, + chat_id: str, + message_id: str, + text: str, + parse_mode: str | None = "MarkdownV2", + fire_and_forget: bool = True, + ) -> None: + """Queue a Telegram edit.""" + await self._outbox.queue_edit_message( + chat_id, + message_id, + text, + parse_mode, + fire_and_forget, + ) + + async def queue_delete_messages( + self, + chat_id: str, + message_ids: list[str], + fire_and_forget: bool = True, + ) -> None: + """Queue a Telegram bulk delete.""" + await self._outbox.queue_delete_messages( + chat_id, + message_ids, + fire_and_forget, + ) + + def fire_and_forget(self, task: Awaitable[Any]) -> None: + """Execute a coroutine without awaiting it.""" + self._outbox.fire_and_forget(task) + + async def close(self) -> None: + """Cancel outstanding outbound work.""" + await self._outbox.close() diff --git a/src/free_claude_code/messaging/platforms/voice_flow.py b/src/free_claude_code/messaging/platforms/voice_flow.py new file mode 100644 index 0000000..c8b4208 --- /dev/null +++ b/src/free_claude_code/messaging/platforms/voice_flow.py @@ -0,0 +1,388 @@ +"""Shared voice-note flow for messaging platform adapters.""" + +import asyncio +import contextlib +import tempfile +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from loguru import logger + +from free_claude_code.core.diagnostics import format_user_error_preview + +from ..models import IncomingMessage, MessageScope +from ..voice import ( + PendingVoiceClaim, + PendingVoiceRegistry, + Transcriber, + VoiceCancellationResult, + VoiceHandoffOutcome, +) + +AUDIO_EXTENSIONS = (".ogg", ".mp4", ".mp3", ".wav", ".m4a") +MAX_AUDIO_SIZE_BYTES = 25 * 1024 * 1024 +VOICE_DISABLED_MESSAGE = "Voice notes are disabled." +VOICE_TRANSCRIPTION_ERROR_MESSAGE = ( + "Could not transcribe voice note. Please try again or send text." +) + +MessageHandler = Callable[[IncomingMessage], Awaitable[None]] +QueueSend = Callable[..., Awaitable[str | None]] +QueueDeleteMany = Callable[..., Awaitable[None]] + + +@dataclass(frozen=True) +class VoiceNoteRequest: + """Platform-normalized voice-note input.""" + + platform: str + chat_id: str + user_id: str + message_id: str + raw_event: Any + content_type: str + temp_suffix: str + status_text: str + download_to: Callable[[Path], Awaitable[None]] + reply_text: Callable[[str], Awaitable[None]] + reply_to_message_id: str | None = None + status_parse_mode: str | None = None + message_thread_id: str | None = None + username: str | None = None + + @property + def scope(self) -> MessageScope: + return MessageScope(platform=self.platform, chat_id=self.chat_id) + + +def is_audio_metadata(filename: str | None, content_type: str | None) -> bool: + """Return whether attachment metadata describes an audio file.""" + normalized_content_type = (content_type or "").lower() + normalized_filename = (filename or "").lower() + return normalized_content_type.startswith("audio/") or any( + normalized_filename.endswith(extension) for extension in AUDIO_EXTENSIONS + ) + + +def audio_suffix_from_metadata( + *, + filename: str | None = None, + content_type: str | None = None, + default: str = ".ogg", +) -> str: + """Choose a temp-file suffix from platform attachment metadata.""" + normalized_filename = (filename or "").lower() + normalized_content_type = (content_type or "").lower() + + if "m4a" in normalized_content_type: + return ".m4a" + if "mp4" in normalized_content_type: + if normalized_filename.endswith(".m4a"): + return ".m4a" + return ".mp4" + if "mpeg" in normalized_content_type or "mp3" in normalized_content_type: + return ".mp3" + if "wav" in normalized_content_type: + return ".wav" + + for extension in AUDIO_EXTENSIONS: + if normalized_filename.endswith(extension): + return extension + return default + + +class VoiceNoteFlow: + """Own common voice transcription state and control flow.""" + + def __init__( + self, + *, + transcriber: Transcriber | None, + log_raw_messaging_content: bool, + log_api_error_tracebacks: bool, + ) -> None: + self._transcriber = transcriber + self._log_raw_messaging_content = log_raw_messaging_content + self._log_api_error_tracebacks = log_api_error_tracebacks + self._pending_voice = PendingVoiceRegistry() + + @property + def is_enabled(self) -> bool: + """Return whether voice-note handling is enabled.""" + return self._transcriber is not None + + async def reply_if_disabled( + self, reply_text: Callable[[str], Awaitable[None]] + ) -> bool: + """Reply with the disabled message when voice-note handling is disabled.""" + if self.is_enabled: + return False + await reply_text(VOICE_DISABLED_MESSAGE) + return True + + async def cancel_pending_voice( + self, scope: MessageScope, reply_id: str + ) -> VoiceCancellationResult | None: + """Cancel a pending voice transcription.""" + return await self._pending_voice.cancel(scope, reply_id) + + async def cancel_all_pending_voices( + self, + ) -> tuple[VoiceCancellationResult, ...]: + """Cancel every pending voice transcription and published handoff.""" + return await self._pending_voice.cancel_all() + + async def cancel_pending_voices_in_scope( + self, + scope: MessageScope, + ) -> tuple[VoiceCancellationResult, ...]: + """Cancel pending voice transcriptions belonging to one chat.""" + return await self._pending_voice.cancel_scope(scope) + + async def handle( + self, + request: VoiceNoteRequest, + *, + message_handler: MessageHandler | None, + queue_send_message: QueueSend, + queue_delete_messages: QueueDeleteMany, + ) -> bool: + """Transcribe a voice note and hand the resulting turn to messaging.""" + if await self.reply_if_disabled(request.reply_text): + return True + + if message_handler is None: + return False + + claim = await self._pending_voice.reserve( + request.scope, + request.message_id, + ) + if claim is None: + return True + + status_msg_id: str | None = None + status_bound = False + tmp_path: Path | None = None + handed_off = False + + try: + delivered_status_id = await queue_send_message( + request.chat_id, + request.status_text, + reply_to=request.message_id, + parse_mode=request.status_parse_mode, + fire_and_forget=False, + message_thread_id=request.message_thread_id, + ) + if delivered_status_id is None: + raise RuntimeError("Voice status delivery returned no message ID.") + status_msg_id = str(delivered_status_id) + status_bound = await self._pending_voice.bind_status(claim, status_msg_id) + if not status_bound: + _, cancellation = await self._finish_failed_pending_voice( + request, + claim, + status_msg_id, + queue_delete_messages, + status_bound=False, + handed_off=False, + ) + if cancellation is not None: + raise cancellation from None + return True + + with tempfile.NamedTemporaryFile( + suffix=request.temp_suffix, delete=False + ) as tmp: + tmp_path = Path(tmp.name) + + await request.download_to(tmp_path) + _validate_audio_file(tmp_path) + + transcriber = self._transcriber + if transcriber is None: + raise RuntimeError("Voice transcription is not configured.") + transcribed = await transcriber.transcribe(tmp_path) + + incoming = IncomingMessage( + text=transcribed, + chat_id=request.chat_id, + user_id=request.user_id, + message_id=request.message_id, + platform=request.platform, + reply_to_message_id=request.reply_to_message_id, + message_thread_id=request.message_thread_id, + username=request.username, + raw_event=request.raw_event, + status_message_id=status_msg_id, + ) + self._log_transcription(request, transcribed) + + async def handle_incoming() -> None: + nonlocal handed_off + handed_off = True + await message_handler(incoming) + + handoff_outcome = await self._pending_voice.handoff( + claim, + handle_incoming, + ) + if handoff_outcome is VoiceHandoffOutcome.REJECTED: + _, cancellation = await self._finish_failed_pending_voice( + request, + claim, + status_msg_id, + queue_delete_messages, + status_bound=status_bound, + handed_off=False, + ) + if cancellation is not None: + raise cancellation from None + return True + return True + except asyncio.CancelledError: + await self._finish_failed_pending_voice( + request, + claim, + status_msg_id, + queue_delete_messages, + status_bound=status_bound, + handed_off=handed_off, + ) + raise + except (ValueError, ImportError) as e: + discarded, cancellation = await self._finish_failed_pending_voice( + request, + claim, + status_msg_id, + queue_delete_messages, + status_bound=status_bound, + handed_off=handed_off, + ) + if cancellation is not None: + raise cancellation from None + if not handed_off and not discarded: + return True + await request.reply_text(format_user_error_preview(e)) + return True + except Exception as e: + discarded, cancellation = await self._finish_failed_pending_voice( + request, + claim, + status_msg_id, + queue_delete_messages, + status_bound=status_bound, + handed_off=handed_off, + ) + if cancellation is not None: + raise cancellation from None + if not handed_off and not discarded: + return True + if self._log_api_error_tracebacks: + logger.error("Voice transcription failed: {}", e) + else: + logger.error( + "Voice transcription failed: exc_type={}", + type(e).__name__, + ) + await request.reply_text(VOICE_TRANSCRIPTION_ERROR_MESSAGE) + return True + except BaseException: + await self._finish_failed_pending_voice( + request, + claim, + status_msg_id, + queue_delete_messages, + status_bound=status_bound, + handed_off=handed_off, + ) + raise + finally: + if tmp_path is not None: + with contextlib.suppress(OSError): + tmp_path.unlink(missing_ok=True) + + async def _finish_failed_pending_voice( + self, + request: VoiceNoteRequest, + claim: PendingVoiceClaim, + status_msg_id: str | None, + queue_delete_messages: QueueDeleteMany, + *, + status_bound: bool, + handed_off: bool, + ) -> tuple[bool, asyncio.CancelledError | None]: + """Finish owned cleanup before propagating any caller cancellation.""" + cleanup_task = asyncio.create_task( + self._clear_failed_pending_voice( + request, + claim, + status_msg_id, + queue_delete_messages, + status_bound=status_bound, + handed_off=handed_off, + ), + name=f"voice-cleanup-{claim.claim_id}", + ) + cancellation: asyncio.CancelledError | None = None + current = asyncio.current_task() + while True: + cancelling_before = current.cancelling() if current is not None else 0 + try: + return await asyncio.shield(cleanup_task), cancellation + except asyncio.CancelledError as error: + if current is None or current.cancelling() <= cancelling_before: + raise + cancellation = cancellation or error + + async def _clear_failed_pending_voice( + self, + request: VoiceNoteRequest, + claim: PendingVoiceClaim, + status_msg_id: str | None, + queue_delete_messages: QueueDeleteMany, + *, + status_bound: bool, + handed_off: bool, + ) -> bool: + discarded = await self._pending_voice.discard(claim) + if ( + not handed_off + and status_msg_id is not None + and (discarded or not status_bound) + ): + with contextlib.suppress(Exception): + await queue_delete_messages(request.chat_id, [status_msg_id]) + return discarded + + def _log_transcription(self, request: VoiceNoteRequest, transcribed: str) -> None: + label = request.platform.upper() + if self._log_raw_messaging_content: + logger.info( + "{}_VOICE: chat_id={} message_id={} transcribed={!r}", + label, + request.chat_id, + request.message_id, + (transcribed[:80] + "..." if len(transcribed) > 80 else transcribed), + ) + else: + logger.info( + "{}_VOICE: chat_id={} message_id={} transcribed_len={}", + label, + request.chat_id, + request.message_id, + len(transcribed), + ) + + +def _validate_audio_file(file_path: Path) -> None: + if not file_path.exists(): + raise FileNotFoundError(f"Audio file not found: {file_path}") + size = file_path.stat().st_size + if size > MAX_AUDIO_SIZE_BYTES: + raise ValueError( + f"Audio file too large ({size} bytes). Max {MAX_AUDIO_SIZE_BYTES} bytes." + ) diff --git a/src/free_claude_code/messaging/rendering/__init__.py b/src/free_claude_code/messaging/rendering/__init__.py new file mode 100644 index 0000000..0c3f2c0 --- /dev/null +++ b/src/free_claude_code/messaging/rendering/__init__.py @@ -0,0 +1,3 @@ +"""Markdown rendering utilities for messaging platforms.""" + +__all__: list[str] = [] diff --git a/src/free_claude_code/messaging/rendering/discord_markdown.py b/src/free_claude_code/messaging/rendering/discord_markdown.py new file mode 100644 index 0000000..ccaf9f4 --- /dev/null +++ b/src/free_claude_code/messaging/rendering/discord_markdown.py @@ -0,0 +1,318 @@ +"""Discord markdown utilities. + +Discord uses standard markdown: **bold**, *italic*, `code`, ```code block```. +Used by the message handler and Discord platform adapter. +""" + +from markdown_it import MarkdownIt + +from .markdown_tables import normalize_gfm_tables + +# Discord escapes: \ * _ ` ~ | > +DISCORD_SPECIAL = set("\\*_`~|>") + +_MD = MarkdownIt("commonmark", {"html": False, "breaks": False}) +_MD.enable("strikethrough") +_MD.enable("table") + + +def escape_discord(text: str) -> str: + """Escape text for Discord markdown (bold, italic, etc.).""" + return "".join(f"\\{ch}" if ch in DISCORD_SPECIAL else ch for ch in text) + + +def escape_discord_code(text: str) -> str: + """Escape text for Discord code spans/blocks.""" + return text.replace("\\", "\\\\").replace("`", "\\`") + + +def discord_bold(text: str) -> str: + """Format text as bold in Discord (uses **).""" + return f"**{escape_discord(text)}**" + + +def discord_code_inline(text: str) -> str: + """Format text as inline code in Discord.""" + return f"`{escape_discord_code(text)}`" + + +def format_status_discord(label: str, suffix: str | None = None) -> str: + """Format a status message for Discord (label in bold, optional suffix).""" + base = discord_bold(label) + if suffix: + return f"{base} {escape_discord(suffix)}" + return base + + +def format_status(emoji: str, label: str, suffix: str | None = None) -> str: + """Format a status message with emoji for Discord (matches Telegram API).""" + base = f"{emoji} {discord_bold(label)}" + if suffix: + return f"{base} {escape_discord(suffix)}" + return base + + +def render_markdown_to_discord(text: str) -> str: + """Render common Markdown into Discord-compatible format.""" + if not text: + return "" + + text = normalize_gfm_tables(text) + tokens = _MD.parse(text) + + def render_inline_table_plain(children) -> str: + out: list[str] = [] + for tok in children: + if tok.type == "text" or tok.type == "code_inline": + out.append(tok.content) + elif tok.type in {"softbreak", "hardbreak"}: + out.append(" ") + elif tok.type == "image" and tok.content: + out.append(tok.content) + return "".join(out) + + def render_inline(children) -> str: + out: list[str] = [] + i = 0 + while i < len(children): + tok = children[i] + t = tok.type + if t == "text": + out.append(escape_discord(tok.content)) + elif t in {"softbreak", "hardbreak"}: + out.append("\n") + elif t == "em_open" or t == "em_close": + out.append("*") + elif t == "strong_open" or t == "strong_close": + out.append("**") + elif t == "s_open" or t == "s_close": + out.append("~~") + elif t == "code_inline": + out.append(f"`{escape_discord_code(tok.content)}`") + elif t == "link_open": + href = "" + if tok.attrs: + if isinstance(tok.attrs, dict): + href = tok.attrs.get("href", "") + else: + for key, val in tok.attrs: + if key == "href": + href = val + break + inner_tokens = [] + i += 1 + while i < len(children) and children[i].type != "link_close": + inner_tokens.append(children[i]) + i += 1 + link_text = "" + for child in inner_tokens: + if child.type == "text" or child.type == "code_inline": + link_text += child.content + out.append(f"[{escape_discord(link_text)}]({href})") + elif t == "image": + href = "" + alt = tok.content or "" + if tok.attrs: + if isinstance(tok.attrs, dict): + href = tok.attrs.get("src", "") + else: + for key, val in tok.attrs: + if key == "src": + href = val + break + if alt: + out.append(f"{escape_discord(alt)} ({href})") + else: + out.append(href) + else: + out.append(escape_discord(tok.content or "")) + i += 1 + return "".join(out) + + out: list[str] = [] + list_stack: list[dict] = [] + pending_prefix: str | None = None + blockquote_level = 0 + in_heading = False + + def apply_blockquote(val: str) -> str: + if blockquote_level <= 0: + return val + prefix = "> " * blockquote_level + return prefix + val.replace("\n", "\n" + prefix) + + i = 0 + while i < len(tokens): + tok = tokens[i] + t = tok.type + if t == "paragraph_open": + pass + elif t == "paragraph_close": + out.append("\n") + elif t == "heading_open": + in_heading = True + elif t == "heading_close": + in_heading = False + out.append("\n") + elif t == "bullet_list_open": + list_stack.append({"type": "bullet", "index": 1}) + elif t == "bullet_list_close": + if list_stack: + list_stack.pop() + out.append("\n") + elif t == "ordered_list_open": + start = 1 + if tok.attrs: + if isinstance(tok.attrs, dict): + val = tok.attrs.get("start") + if val is not None: + try: + start = int(val) + except TypeError, ValueError: + start = 1 + else: + for key, val in tok.attrs: + if key == "start": + try: + start = int(val) + except TypeError, ValueError: + start = 1 + break + list_stack.append({"type": "ordered", "index": start}) + elif t == "ordered_list_close": + if list_stack: + list_stack.pop() + out.append("\n") + elif t == "list_item_open": + if list_stack: + top = list_stack[-1] + if top["type"] == "bullet": + pending_prefix = "- " + else: + pending_prefix = f"{top['index']}. " + top["index"] += 1 + elif t == "list_item_close": + out.append("\n") + elif t == "blockquote_open": + blockquote_level += 1 + elif t == "blockquote_close": + blockquote_level = max(0, blockquote_level - 1) + out.append("\n") + elif t == "table_open": + if pending_prefix: + out.append(apply_blockquote(pending_prefix.rstrip())) + out.append("\n") + pending_prefix = None + + rows: list[list[str]] = [] + row_is_header: list[bool] = [] + + j = i + 1 + in_thead = False + in_row = False + current_row: list[str] = [] + current_row_header = False + + in_cell = False + cell_parts: list[str] = [] + + while j < len(tokens): + tt = tokens[j].type + if tt == "thead_open": + in_thead = True + elif tt == "thead_close": + in_thead = False + elif tt == "tr_open": + in_row = True + current_row = [] + current_row_header = in_thead + elif tt in {"th_open", "td_open"}: + in_cell = True + cell_parts = [] + elif tt == "inline" and in_cell: + cell_parts.append( + render_inline_table_plain(tokens[j].children or []) + ) + elif tt in {"th_close", "td_close"} and in_cell: + cell = " ".join(cell_parts).strip() + current_row.append(cell) + in_cell = False + cell_parts = [] + elif tt == "tr_close" and in_row: + rows.append(current_row) + row_is_header.append(bool(current_row_header)) + in_row = False + elif tt == "table_close": + break + j += 1 + + if rows: + col_count = max((len(r) for r in rows), default=0) + norm_rows: list[list[str]] = [] + for r in rows: + if len(r) < col_count: + r = r + [""] * (col_count - len(r)) + norm_rows.append(r) + + widths: list[int] = [] + for c in range(col_count): + w = max((len(r[c]) for r in norm_rows), default=0) + widths.append(max(w, 3)) + + def fmt_row( + r: list[str], _w: list[int] = widths, _c: int = col_count + ) -> str: + cells = [r[c].ljust(_w[c]) for c in range(_c)] + return "| " + " | ".join(cells) + " |" + + def fmt_sep(_w: list[int] = widths, _c: int = col_count) -> str: + cells = ["-" * _w[c] for c in range(_c)] + return "| " + " | ".join(cells) + " |" + + last_header_idx = -1 + for idx, is_h in enumerate(row_is_header): + if is_h: + last_header_idx = idx + + lines: list[str] = [] + for idx, r in enumerate(norm_rows): + lines.append(fmt_row(r)) + if idx == last_header_idx: + lines.append(fmt_sep()) + + table_text = "\n".join(lines).rstrip() + out.append(f"```\n{escape_discord_code(table_text)}\n```") + out.append("\n") + + i = j + 1 + continue + elif t in {"code_block", "fence"}: + code = escape_discord_code(tok.content.rstrip("\n")) + out.append(f"```\n{code}\n```") + out.append("\n") + elif t == "inline": + rendered = render_inline(tok.children or []) + if in_heading: + rendered = f"**{render_inline(tok.children or [])}**" + if pending_prefix: + rendered = pending_prefix + rendered + pending_prefix = None + rendered = apply_blockquote(rendered) + out.append(rendered) + else: + if tok.content: + out.append(escape_discord(tok.content)) + i += 1 + + return "".join(out).rstrip() + + +__all__ = [ + "discord_bold", + "discord_code_inline", + "escape_discord", + "escape_discord_code", + "format_status", + "format_status_discord", + "render_markdown_to_discord", +] diff --git a/src/free_claude_code/messaging/rendering/markdown_tables.py b/src/free_claude_code/messaging/rendering/markdown_tables.py new file mode 100644 index 0000000..f5d7c95 --- /dev/null +++ b/src/free_claude_code/messaging/rendering/markdown_tables.py @@ -0,0 +1,47 @@ +"""Shared Markdown table pre-normalization for platform renderers.""" + +import re + +_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$") +_FENCE_RE = re.compile(r"^\s*```") + + +def _is_gfm_table_header_line(line: str) -> bool: + """Return whether a line looks like a GFM table header.""" + if "|" not in line: + return False + if _TABLE_SEP_RE.match(line): + return False + parts = [part.strip() for part in line.strip().strip("|").split("|")] + return len([part for part in parts if part]) >= 2 + + +def normalize_gfm_tables(text: str) -> str: + """Insert blank lines before detected tables outside fenced code blocks.""" + lines = text.splitlines() + if len(lines) < 2: + return text + + out_lines: list[str] = [] + in_fence = False + + for idx, line in enumerate(lines): + if _FENCE_RE.match(line): + in_fence = not in_fence + out_lines.append(line) + continue + + if ( + not in_fence + and idx + 1 < len(lines) + and _is_gfm_table_header_line(line) + and _TABLE_SEP_RE.match(lines[idx + 1]) + and out_lines + and out_lines[-1].strip() != "" + ): + indent_match = re.match(r"^(\s*)", line) + out_lines.append(indent_match.group(1) if indent_match else "") + + out_lines.append(line) + + return "\n".join(out_lines) diff --git a/src/free_claude_code/messaging/rendering/profiles.py b/src/free_claude_code/messaging/rendering/profiles.py new file mode 100644 index 0000000..ae70297 --- /dev/null +++ b/src/free_claude_code/messaging/rendering/profiles.py @@ -0,0 +1,53 @@ +"""Platform rendering profiles for messaging transcripts and status text.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from free_claude_code.messaging.rendering.discord_markdown import ( + discord_bold, + discord_code_inline, + escape_discord, + escape_discord_code, + render_markdown_to_discord, +) +from free_claude_code.messaging.rendering.discord_markdown import ( + format_status as format_status_discord, +) +from free_claude_code.messaging.rendering.telegram_markdown import ( + escape_md_v2, + escape_md_v2_code, + mdv2_bold, + mdv2_code_inline, + render_markdown_to_mdv2, +) +from free_claude_code.messaging.rendering.telegram_markdown import ( + format_status as format_status_telegram, +) +from free_claude_code.messaging.transcript import RenderCtx + + +@dataclass(frozen=True, slots=True) +class RenderingProfile: + format_status: Callable[[str, str, str | None], str] + parse_mode: str | None + render_ctx: RenderCtx + limit_chars: int + + +def build_rendering_profile(platform_name: str) -> RenderingProfile: + """Return rendering rules for a messaging platform.""" + is_discord = platform_name == "discord" + return RenderingProfile( + format_status=format_status_discord if is_discord else format_status_telegram, + parse_mode=None if is_discord else "MarkdownV2", + render_ctx=RenderCtx( + bold=discord_bold if is_discord else mdv2_bold, + code_inline=discord_code_inline if is_discord else mdv2_code_inline, + escape_code=escape_discord_code if is_discord else escape_md_v2_code, + escape_text=escape_discord if is_discord else escape_md_v2, + render_markdown=render_markdown_to_discord + if is_discord + else render_markdown_to_mdv2, + ), + limit_chars=1900 if is_discord else 3900, + ) diff --git a/src/free_claude_code/messaging/rendering/telegram_markdown.py b/src/free_claude_code/messaging/rendering/telegram_markdown.py new file mode 100644 index 0000000..b0c24c6 --- /dev/null +++ b/src/free_claude_code/messaging/rendering/telegram_markdown.py @@ -0,0 +1,327 @@ +"""Telegram MarkdownV2 utilities. + +Renders common Markdown into Telegram MarkdownV2 format. +Used by the message handler and Telegram platform adapter. +""" + +from markdown_it import MarkdownIt + +from .markdown_tables import normalize_gfm_tables + +MDV2_SPECIAL_CHARS = set("\\_*[]()~`>#+-=|{}.!") +MDV2_LINK_ESCAPE = set("\\)") + +_MD = MarkdownIt("commonmark", {"html": False, "breaks": False}) +_MD.enable("strikethrough") +_MD.enable("table") + + +def escape_md_v2(text: str) -> str: + """Escape text for Telegram MarkdownV2.""" + return "".join(f"\\{ch}" if ch in MDV2_SPECIAL_CHARS else ch for ch in text) + + +def escape_md_v2_code(text: str) -> str: + """Escape text for Telegram MarkdownV2 code spans/blocks.""" + return text.replace("\\", "\\\\").replace("`", "\\`") + + +def escape_md_v2_link_url(text: str) -> str: + """Escape URL for Telegram MarkdownV2 link destination.""" + return "".join(f"\\{ch}" if ch in MDV2_LINK_ESCAPE else ch for ch in text) + + +def mdv2_bold(text: str) -> str: + """Format text as bold in MarkdownV2.""" + return f"*{escape_md_v2(text)}*" + + +def mdv2_code_inline(text: str) -> str: + """Format text as inline code in MarkdownV2.""" + return f"`{escape_md_v2_code(text)}`" + + +def format_status(emoji: str, label: str, suffix: str | None = None) -> str: + """Format a status message with emoji and optional suffix.""" + base = f"{emoji} {mdv2_bold(label)}" + if suffix: + return f"{base} {escape_md_v2(suffix)}" + return base + + +def render_markdown_to_mdv2(text: str) -> str: + """Render common Markdown into Telegram MarkdownV2.""" + if not text: + return "" + + text = normalize_gfm_tables(text) + tokens = _MD.parse(text) + + def render_inline_table_plain(children) -> str: + out: list[str] = [] + for tok in children: + if tok.type == "text" or tok.type == "code_inline": + out.append(tok.content) + elif tok.type in {"softbreak", "hardbreak"}: + out.append(" ") + elif tok.type == "image" and tok.content: + out.append(tok.content) + return "".join(out) + + def render_inline_plain(children) -> str: + out: list[str] = [] + for tok in children: + if tok.type == "text" or tok.type == "code_inline": + out.append(escape_md_v2(tok.content)) + elif tok.type in {"softbreak", "hardbreak"}: + out.append("\n") + return "".join(out) + + def render_inline(children) -> str: + out: list[str] = [] + i = 0 + while i < len(children): + tok = children[i] + t = tok.type + if t == "text": + out.append(escape_md_v2(tok.content)) + elif t in {"softbreak", "hardbreak"}: + out.append("\n") + elif t == "em_open" or t == "em_close": + out.append("_") + elif t == "strong_open" or t == "strong_close": + out.append("*") + elif t == "s_open" or t == "s_close": + out.append("~") + elif t == "code_inline": + out.append(f"`{escape_md_v2_code(tok.content)}`") + elif t == "link_open": + href = "" + if tok.attrs: + if isinstance(tok.attrs, dict): + href = tok.attrs.get("href", "") + else: + for key, val in tok.attrs: + if key == "href": + href = val + break + inner_tokens = [] + i += 1 + while i < len(children) and children[i].type != "link_close": + inner_tokens.append(children[i]) + i += 1 + link_text = "" + for child in inner_tokens: + if child.type == "text" or child.type == "code_inline": + link_text += child.content + out.append( + f"[{escape_md_v2(link_text)}]({escape_md_v2_link_url(href)})" + ) + elif t == "image": + href = "" + alt = tok.content or "" + if tok.attrs: + if isinstance(tok.attrs, dict): + href = tok.attrs.get("src", "") + else: + for key, val in tok.attrs: + if key == "src": + href = val + break + if alt: + out.append(f"{escape_md_v2(alt)} ({escape_md_v2_link_url(href)})") + else: + out.append(escape_md_v2_link_url(href)) + else: + out.append(escape_md_v2(tok.content or "")) + i += 1 + return "".join(out) + + out: list[str] = [] + list_stack: list[dict] = [] + pending_prefix: str | None = None + blockquote_level = 0 + in_heading = False + + def apply_blockquote(val: str) -> str: + if blockquote_level <= 0: + return val + prefix = "> " * blockquote_level + return prefix + val.replace("\n", "\n" + prefix) + + i = 0 + while i < len(tokens): + tok = tokens[i] + t = tok.type + if t == "paragraph_open": + pass + elif t == "paragraph_close": + out.append("\n") + elif t == "heading_open": + in_heading = True + elif t == "heading_close": + in_heading = False + out.append("\n") + elif t == "bullet_list_open": + list_stack.append({"type": "bullet", "index": 1}) + elif t == "bullet_list_close": + if list_stack: + list_stack.pop() + out.append("\n") + elif t == "ordered_list_open": + start = 1 + if tok.attrs: + if isinstance(tok.attrs, dict): + val = tok.attrs.get("start") + if val is not None: + try: + start = int(val) + except TypeError, ValueError: + start = 1 + else: + for key, val in tok.attrs: + if key == "start": + try: + start = int(val) + except TypeError, ValueError: + start = 1 + break + list_stack.append({"type": "ordered", "index": start}) + elif t == "ordered_list_close": + if list_stack: + list_stack.pop() + out.append("\n") + elif t == "list_item_open": + if list_stack: + top = list_stack[-1] + if top["type"] == "bullet": + pending_prefix = "\\- " + else: + pending_prefix = f"{top['index']}\\." + top["index"] += 1 + pending_prefix += " " + elif t == "list_item_close": + out.append("\n") + elif t == "blockquote_open": + blockquote_level += 1 + elif t == "blockquote_close": + blockquote_level = max(0, blockquote_level - 1) + out.append("\n") + elif t == "table_open": + if pending_prefix: + out.append(apply_blockquote(pending_prefix.rstrip())) + out.append("\n") + pending_prefix = None + + rows: list[list[str]] = [] + row_is_header: list[bool] = [] + + j = i + 1 + in_thead = False + in_row = False + current_row: list[str] = [] + current_row_header = False + + in_cell = False + cell_parts: list[str] = [] + + while j < len(tokens): + tt = tokens[j].type + if tt == "thead_open": + in_thead = True + elif tt == "thead_close": + in_thead = False + elif tt == "tr_open": + in_row = True + current_row = [] + current_row_header = in_thead + elif tt in {"th_open", "td_open"}: + in_cell = True + cell_parts = [] + elif tt == "inline" and in_cell: + cell_parts.append( + render_inline_table_plain(tokens[j].children or []) + ) + elif tt in {"th_close", "td_close"} and in_cell: + cell = " ".join(cell_parts).strip() + current_row.append(cell) + in_cell = False + cell_parts = [] + elif tt == "tr_close" and in_row: + rows.append(current_row) + row_is_header.append(bool(current_row_header)) + in_row = False + elif tt == "table_close": + break + j += 1 + + if rows: + col_count = max((len(r) for r in rows), default=0) + norm_rows: list[list[str]] = [] + for r in rows: + if len(r) < col_count: + r = r + [""] * (col_count - len(r)) + norm_rows.append(r) + + widths: list[int] = [] + for c in range(col_count): + w = max((len(r[c]) for r in norm_rows), default=0) + widths.append(max(w, 3)) + + def fmt_row( + r: list[str], _w: list[int] = widths, _c: int = col_count + ) -> str: + cells = [r[c].ljust(_w[c]) for c in range(_c)] + return "| " + " | ".join(cells) + " |" + + def fmt_sep(_w: list[int] = widths, _c: int = col_count) -> str: + cells = ["-" * _w[c] for c in range(_c)] + return "| " + " | ".join(cells) + " |" + + last_header_idx = -1 + for idx, is_h in enumerate(row_is_header): + if is_h: + last_header_idx = idx + + lines: list[str] = [] + for idx, r in enumerate(norm_rows): + lines.append(fmt_row(r)) + if idx == last_header_idx: + lines.append(fmt_sep()) + + table_text = "\n".join(lines).rstrip() + out.append(f"```\n{escape_md_v2_code(table_text)}\n```") + out.append("\n") + + i = j + 1 + continue + elif t in {"code_block", "fence"}: + code = escape_md_v2_code(tok.content.rstrip("\n")) + out.append(f"```\n{code}\n```") + out.append("\n") + elif t == "inline": + rendered = render_inline(tok.children or []) + if in_heading: + rendered = f"*{render_inline_plain(tok.children or [])}*" + if pending_prefix: + rendered = pending_prefix + rendered + pending_prefix = None + rendered = apply_blockquote(rendered) + out.append(rendered) + else: + if tok.content: + out.append(escape_md_v2(tok.content)) + i += 1 + + return "".join(out).rstrip() + + +__all__ = [ + "escape_md_v2", + "escape_md_v2_code", + "escape_md_v2_link_url", + "format_status", + "mdv2_bold", + "mdv2_code_inline", + "render_markdown_to_mdv2", +] diff --git a/src/free_claude_code/messaging/safe_diagnostics.py b/src/free_claude_code/messaging/safe_diagnostics.py new file mode 100644 index 0000000..edd3d62 --- /dev/null +++ b/src/free_claude_code/messaging/safe_diagnostics.py @@ -0,0 +1,15 @@ +"""Helpers for redacting user-derived content from log lines.""" + + +def format_exception_for_log(exc: BaseException, *, log_full_message: bool) -> str: + """Return exception type and optionally ``str(exc)`` for operator diagnostics.""" + if log_full_message: + return f"{type(exc).__name__}: {exc}" + return type(exc).__name__ + + +def text_len_hint(text: str | None) -> int: + """Length of text for metadata-only logging (0 when missing).""" + if not text: + return 0 + return len(text) diff --git a/src/free_claude_code/messaging/session/__init__.py b/src/free_claude_code/messaging/session/__init__.py new file mode 100644 index 0000000..cdf1f36 --- /dev/null +++ b/src/free_claude_code/messaging/session/__init__.py @@ -0,0 +1,5 @@ +"""Public messaging session persistence API.""" + +from .store import SessionStore + +__all__ = ["SessionStore"] diff --git a/src/free_claude_code/messaging/session/managed_message_log.py b/src/free_claude_code/messaging/session/managed_message_log.py new file mode 100644 index 0000000..c62e2ad --- /dev/null +++ b/src/free_claude_code/messaging/session/managed_message_log.py @@ -0,0 +1,135 @@ +"""Persist platform messages belonging to FCC-managed conversations.""" + +from datetime import UTC, datetime +from typing import Any + + +class ManagedMessageLog: + """Track managed inbound and outbound messages in insertion order.""" + + def __init__(self, *, cap: int | None = None) -> None: + self._items: dict[str, list[dict[str, Any]]] = {} + self._ids: dict[str, set[str]] = {} + self._cap = cap + + @property + def cap(self) -> int | None: + return self._cap + + @classmethod + def from_json(cls, raw_log: Any, *, cap: int | None = None) -> ManagedMessageLog: + """Load current and legacy message-log entries.""" + log = cls(cap=cap) + if not isinstance(raw_log, dict): + return log + for chat_key, items in raw_log.items(): + if not isinstance(chat_key, str) or not isinstance(items, list): + continue + for item in items: + if not isinstance(item, dict): + continue + message_id = item.get("message_id") + direction = str(item.get("direction") or "") + kind = str(item.get("kind") or "") + if message_id is None or direction not in {"in", "out"} or not kind: + continue + log._append( + chat_key, + str(message_id), + ts=str(item.get("ts") or ""), + direction=direction, + kind=kind, + ) + return log + + def to_json(self) -> dict[str, list[dict[str, Any]]]: + return {chat_key: list(items) for chat_key, items in self._items.items()} + + def record( + self, + *, + platform: str, + chat_id: str, + message_id: str, + direction: str, + kind: str, + ) -> bool: + """Record one managed platform message.""" + if direction not in {"in", "out"}: + raise ValueError("Managed message direction must be 'in' or 'out'") + if not kind: + raise ValueError("Managed message kind cannot be empty") + return self._append( + make_chat_key(platform, chat_id), + str(message_id), + ts=datetime.now(UTC).isoformat(), + direction=direction, + kind=str(kind), + ) + + def ids_for_chat(self, platform: str, chat_id: str) -> list[str]: + chat_key = make_chat_key(platform, chat_id) + return [str(item["message_id"]) for item in self._items.get(chat_key, [])] + + def remove_ids(self, platform: str, chat_id: str, message_ids: set[str]) -> bool: + chat_key = make_chat_key(platform, chat_id) + if not message_ids or chat_key not in self._items: + return False + + before_count = len(self._items[chat_key]) + retained = [ + item + for item in self._items[chat_key] + if str(item["message_id"]) not in message_ids + ] + if retained: + self._items[chat_key] = retained + self._ids[chat_key] = {str(item["message_id"]) for item in retained} + else: + self._items.pop(chat_key) + self._ids.pop(chat_key, None) + return len(retained) != before_count + + def clear_chat(self, platform: str, chat_id: str) -> bool: + chat_key = make_chat_key(platform, chat_id) + removed = self._items.pop(chat_key, None) is not None + self._ids.pop(chat_key, None) + return removed + + def _append( + self, + chat_key: str, + message_id: str, + *, + ts: str, + direction: str, + kind: str, + ) -> bool: + seen = self._ids.setdefault(chat_key, set()) + if message_id in seen: + return False + self._items.setdefault(chat_key, []).append( + { + "message_id": message_id, + "ts": ts, + "direction": direction, + "kind": kind, + } + ) + seen.add(message_id) + self._trim(chat_key) + return True + + def _trim(self, chat_key: str) -> None: + if self._cap is None or self._cap <= 0: + return + items = self._items.get(chat_key, []) + if len(items) <= self._cap: + return + retained = items[-self._cap :] + self._items[chat_key] = retained + self._ids[chat_key] = {str(item["message_id"]) for item in retained} + + +def make_chat_key(platform: str, chat_id: str) -> str: + return f"{platform}:{chat_id}" diff --git a/src/free_claude_code/messaging/session/persistence.py b/src/free_claude_code/messaging/session/persistence.py new file mode 100644 index 0000000..7849e62 --- /dev/null +++ b/src/free_claude_code/messaging/session/persistence.py @@ -0,0 +1,161 @@ +"""Atomic JSON persistence for messaging session state.""" + +import contextlib +import json +import os +import tempfile +import threading +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from loguru import logger + + +@dataclass(frozen=True) +class _PendingWrite: + generation: int + snapshot: dict[str, Any] + + +class DebouncedJsonPersistence: + """Thread-safe debounced JSON writer with atomic replace semantics.""" + + def __init__( + self, + storage_path: str, + *, + snapshot: Callable[[], dict[str, Any]], + on_dirty: Callable[[bool], None], + debounce_secs: float = 0.5, + ) -> None: + self.storage_path = storage_path + self._snapshot = snapshot + self._on_dirty = on_dirty + self._debounce_secs = debounce_secs + self._save_timer: threading.Timer | None = None + self._timer_lock = threading.Lock() + self._writer_lock = threading.Lock() + self._save_generation = 0 + + def load_json(self) -> dict[str, Any]: + if not os.path.exists(self.storage_path): + return {} + with open(self.storage_path, encoding="utf-8") as file: + data = json.load(file) + return data if isinstance(data, dict) else {} + + def schedule_save(self) -> None: + self._on_dirty(True) + with self._timer_lock: + if self._save_timer is not None: + self._save_timer.cancel() + self._save_generation += 1 + generation = self._save_generation + timer = threading.Timer( + self._debounce_secs, + self._save_from_timer, + args=(generation,), + ) + timer.daemon = True + self._save_timer = timer + timer.start() + + def flush(self) -> None: + self._on_dirty(True) + pending = self._snapshot_for_write() + if pending is None: + return + self._write_pending(pending) + + def _save_from_timer(self, generation: int) -> None: + try: + pending = self._snapshot_for_write(expected_generation=generation) + if pending is None: + return + self._write_pending(pending) + except Exception as e: + self._on_dirty(True) + logger.error( + "Failed to save sessions: exc_type={}", + type(e).__name__, + ) + + def _write_pending(self, pending: _PendingWrite) -> None: + try: + written = self._write_if_current(pending) + except Exception: + self._on_dirty(True) + raise + if written: + self._mark_clean_if_current(pending.generation) + + def _write_if_current(self, pending: _PendingWrite) -> bool: + """Serialize writers and reject a snapshot superseded before replace.""" + with self._writer_lock: + with self._timer_lock: + if pending.generation != self._save_generation: + return False + self._write_file(pending.snapshot) + return True + + def _snapshot_for_write( + self, *, expected_generation: int | None = None + ) -> _PendingWrite | None: + generation = self._claim_timer(expected_generation) + if generation is None: + return None + snapshot = self._snapshot() + return _PendingWrite(generation=generation, snapshot=snapshot) + + def _claim_timer(self, expected_generation: int | None) -> int | None: + with self._timer_lock: + if expected_generation is not None and ( + expected_generation != self._save_generation or self._save_timer is None + ): + return None + if self._save_timer is not None: + self._save_timer.cancel() + self._save_timer = None + return self._save_generation + + def _mark_clean_if_current(self, generation: int) -> None: + with self._timer_lock: + is_current = ( + self._save_timer is None and generation == self._save_generation + ) + if is_current: + self._on_dirty(False) + + def write_data(self, data: dict[str, Any]) -> None: + """Write authoritative state after invalidating older pending snapshots.""" + self._on_dirty(True) + with self._timer_lock: + if self._save_timer is not None: + self._save_timer.cancel() + self._save_timer = None + self._save_generation += 1 + pending = _PendingWrite( + generation=self._save_generation, + snapshot=data, + ) + self._write_pending(pending) + + def _write_file(self, data: dict[str, Any]) -> None: + abs_target = os.path.abspath(self.storage_path) + dir_name = os.path.dirname(abs_target) or "." + fd, tmp_path = tempfile.mkstemp( + dir=dir_name, + prefix=".sessions.", + suffix=".tmp.json", + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as file: + json.dump(data, file, indent=2) + file.flush() + os.fsync(file.fileno()) + os.replace(tmp_path, abs_target) + except BaseException: + with contextlib.suppress(OSError): + os.unlink(tmp_path) + raise diff --git a/src/free_claude_code/messaging/session/store.py b/src/free_claude_code/messaging/session/store.py new file mode 100644 index 0000000..674aa72 --- /dev/null +++ b/src/free_claude_code/messaging/session/store.py @@ -0,0 +1,162 @@ +"""Persistent messaging conversation state store.""" + +import threading +from copy import deepcopy + +from loguru import logger + +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.trees import ( + ConversationSnapshot, + TreeIdentity, + TreeSnapshot, +) + +from .managed_message_log import ManagedMessageLog +from .persistence import DebouncedJsonPersistence + + +class SessionStore: + """ + Persistent storage for conversation snapshots and managed platform messages. + + The store reads both the old raw ``trees``/``node_to_tree`` shape and the + current typed ``conversation`` snapshot shape. Runtime callers deal in typed + snapshots only. + """ + + def __init__( + self, + storage_path: str = "sessions.json", + *, + managed_message_cap: int | None = None, + ) -> None: + self.storage_path = storage_path + self._lock = threading.RLock() + self._conversation = ConversationSnapshot() + self._managed_messages = ManagedMessageLog(cap=managed_message_cap) + self._dirty = False + self._persistence = DebouncedJsonPersistence( + storage_path, + snapshot=self._snapshot_for_persistence, + on_dirty=self._set_dirty, + ) + self._load() + + @property + def dirty(self) -> bool: + return self._dirty + + def _set_dirty(self, dirty: bool) -> None: + with self._lock: + self._dirty = dirty + + def _load(self) -> None: + try: + data = self._persistence.load_json() + except Exception as e: + logger.error("Failed to load sessions: {}", e) + return + + conversation_data = data.get("conversation") if isinstance(data, dict) else None + if not isinstance(conversation_data, dict): + conversation_data = data + + with self._lock: + self._conversation = ConversationSnapshot.from_json(conversation_data) + raw_messages = {} + if isinstance(data, dict): + raw_messages = data.get("managed_messages", data.get("message_log", {})) + self._managed_messages = ManagedMessageLog.from_json( + raw_messages, + cap=self._managed_messages.cap, + ) + message_count = sum( + len(items) for items in self._managed_messages.to_json().values() + ) + logger.info( + "Loaded {} trees and {} managed message IDs from {}", + len(self._conversation.trees), + message_count, + self.storage_path, + ) + + def _snapshot_for_persistence(self) -> dict: + with self._lock: + return { + "conversation": self._conversation.to_json(), + "managed_messages": self._managed_messages.to_json(), + } + + def load_conversation_snapshot(self) -> ConversationSnapshot: + with self._lock: + return deepcopy(self._conversation) + + def save_conversation_snapshot(self, snapshot: ConversationSnapshot) -> None: + with self._lock: + self._conversation = deepcopy(snapshot) + self._persistence.schedule_save() + + def save_tree_snapshot(self, snapshot: TreeSnapshot) -> None: + with self._lock: + self._conversation = self._conversation.with_tree(deepcopy(snapshot)) + self._persistence.schedule_save() + logger.debug("Saved tree {}", snapshot.root_id) + + def remove_tree_snapshot(self, identity: TreeIdentity) -> None: + with self._lock: + self._conversation = self._conversation.without_tree(identity) + self._persistence.schedule_save() + + def flush_pending_save(self) -> None: + self._persistence.flush() + + def record_message_id( + self, + platform: str, + chat_id: str, + message_id: str, + direction: str, + kind: str, + ) -> None: + if message_id is None: + return + with self._lock: + recorded = self._managed_messages.record( + platform=str(platform), + chat_id=str(chat_id), + message_id=str(message_id), + direction=str(direction), + kind=str(kind), + ) + if recorded: + self._persistence.schedule_save() + + def get_tracked_message_ids_for_chat( + self, platform: str, chat_id: str + ) -> list[str]: + with self._lock: + return self._managed_messages.ids_for_chat(str(platform), str(chat_id)) + + def forget_tracked_message_ids( + self, platform: str, chat_id: str, message_ids: set[str] + ) -> None: + with self._lock: + removed = self._managed_messages.remove_ids( + str(platform), + str(chat_id), + {str(message_id) for message_id in message_ids}, + ) + if removed: + self._persistence.schedule_save() + + def clear_scope(self, scope: MessageScope) -> None: + """Authoritatively clear one platform chat while preserving others.""" + with self._lock: + self._conversation = self._conversation.without_scope(scope) + self._managed_messages.clear_chat(scope.platform, scope.chat_id) + self._write_current_state() + + def _write_current_state(self) -> None: + self._set_dirty(True) + self._persistence.write_data(self._snapshot_for_persistence()) diff --git a/src/free_claude_code/messaging/transcript/__init__.py b/src/free_claude_code/messaging/transcript/__init__.py new file mode 100644 index 0000000..881befc --- /dev/null +++ b/src/free_claude_code/messaging/transcript/__init__.py @@ -0,0 +1,6 @@ +"""Public transcript API for messaging UI rendering.""" + +from .buffer import TranscriptBuffer +from .context import RenderCtx + +__all__ = ["RenderCtx", "TranscriptBuffer"] diff --git a/src/free_claude_code/messaging/transcript/buffer.py b/src/free_claude_code/messaging/transcript/buffer.py new file mode 100644 index 0000000..a99e88b --- /dev/null +++ b/src/free_claude_code/messaging/transcript/buffer.py @@ -0,0 +1,220 @@ +"""Transcript event application and open-block tracking.""" + +from typing import Any + +from .context import RenderCtx +from .renderer import render_segments +from .segments import ( + ErrorSegment, + Segment, + SubagentSegment, + TextSegment, + ThinkingSegment, + ToolCallSegment, + ToolResultSegment, +) +from .subagents import SubagentState, task_heading_from_input + +_SUBAGENT_SUPPRESSED_EVENTS = frozenset( + { + "thinking_start", + "thinking_delta", + "thinking_chunk", + "text_start", + "text_delta", + "text_chunk", + } +) + + +class TranscriptBuffer: + """Maintains an ordered, truncatable transcript of parsed CLI events.""" + + def __init__( + self, + *, + show_tool_results: bool = True, + debug_subagent_stack: bool = False, + ) -> None: + self._segments: list[Segment] = [] + self._open_thinking_by_index: dict[int, ThinkingSegment] = {} + self._open_text_by_index: dict[int, TextSegment] = {} + self._open_tools_by_index: dict[int, ToolCallSegment] = {} + self._tool_name_by_id: dict[str, str] = {} + self._show_tool_results = bool(show_tool_results) + self._subagents = SubagentState(debug=debug_subagent_stack) + + def apply(self, event: dict[str, Any]) -> None: + """Apply a parsed CLI transcript event.""" + event_type = event.get("type") + if self._subagents.in_subagent() and event_type in _SUBAGENT_SUPPRESSED_EVENTS: + return + + if event_type == "thinking_start": + self._start_thinking(_event_index(event)) + return + if event_type in ("thinking_delta", "thinking_chunk"): + self._append_thinking(_event_index(event), str(event.get("text", ""))) + return + if event_type == "thinking_stop": + self._open_thinking_by_index.pop(_event_index(event), None) + return + + if event_type == "text_start": + self._start_text(_event_index(event)) + return + if event_type in ("text_delta", "text_chunk"): + self._append_text(_event_index(event), str(event.get("text", ""))) + return + if event_type == "text_stop": + self._open_text_by_index.pop(_event_index(event), None) + return + + if event_type == "tool_use_start": + self._start_tool_use(event) + return + if event_type == "tool_use_delta": + return + if event_type == "tool_use_stop": + segment = self._open_tools_by_index.pop(_event_index(event), None) + if segment is not None: + segment.closed = True + return + + if event_type == "block_stop": + self._close_block(_event_index(event)) + return + if event_type == "tool_use": + self._append_complete_tool_use(event) + return + if event_type == "tool_result": + self._append_tool_result(event) + return + if event_type == "error": + self._segments.append(ErrorSegment(str(event.get("message", "")))) + + def render(self, ctx: RenderCtx, *, limit_chars: int, status: str | None) -> str: + return render_segments( + self._segments, + ctx, + limit_chars=limit_chars, + status=status, + ) + + def _start_thinking(self, index: int) -> None: + if index >= 0: + self._close_block(index) + segment = ThinkingSegment() + self._segments.append(segment) + if index >= 0: + self._open_thinking_by_index[index] = segment + + def _append_thinking(self, index: int, text: str) -> None: + segment = self._open_thinking_by_index.get(index) + if segment is None: + segment = ThinkingSegment() + self._segments.append(segment) + if index >= 0: + self._open_thinking_by_index[index] = segment + segment.append(text) + + def _start_text(self, index: int) -> None: + if index >= 0: + self._close_block(index) + segment = TextSegment() + self._segments.append(segment) + if index >= 0: + self._open_text_by_index[index] = segment + + def _append_text(self, index: int, text: str) -> None: + segment = self._open_text_by_index.get(index) + if segment is None: + segment = TextSegment() + self._segments.append(segment) + if index >= 0: + self._open_text_by_index[index] = segment + segment.append(text) + + def _start_tool_use(self, event: dict[str, Any]) -> None: + index = _event_index(event) + if index >= 0: + self._close_block(index) + + tool_id = _event_tool_id(event, "id") + name = str(event.get("name", "") or "tool") + if tool_id: + self._tool_name_by_id[tool_id] = name + + if name == "Task": + segment = SubagentSegment(task_heading_from_input(event.get("input"))) + self._segments.append(segment) + self._subagents.push(tool_id, segment) + return + + segment = self._append_tool_call(tool_id, name) + if index >= 0: + self._open_tools_by_index[index] = segment + + def _append_complete_tool_use(self, event: dict[str, Any]) -> None: + tool_id = _event_tool_id(event, "id") + name = str(event.get("name", "") or "tool") + if tool_id: + self._tool_name_by_id[tool_id] = name + + if name == "Task": + segment = SubagentSegment(task_heading_from_input(event.get("input"))) + self._segments.append(segment) + self._subagents.push(tool_id, segment) + return + + segment = self._append_tool_call(tool_id, name) + segment.closed = True + + def _append_tool_call(self, tool_id: str, name: str) -> ToolCallSegment: + if self._subagents.in_subagent(): + parent = self._subagents.current_segment() + if parent is not None: + return parent.set_current_tool_call(tool_id, name) + + segment = ToolCallSegment(tool_id, name) + self._segments.append(segment) + return segment + + def _append_tool_result(self, event: dict[str, Any]) -> None: + tool_id = _event_tool_id(event, "tool_use_id") + name = self._tool_name_by_id.get(tool_id) + + if self._subagents.in_subagent(): + self._subagents.close_for_tool_result(tool_id, tool_name=name) + + if not self._show_tool_results: + return + + self._segments.append( + ToolResultSegment( + tool_id, + event.get("content"), + name=name, + is_error=bool(event.get("is_error", False)), + ) + ) + + def _close_block(self, index: int) -> None: + if index in self._open_tools_by_index: + segment = self._open_tools_by_index.pop(index, None) + if segment is not None: + segment.closed = True + return + if index in self._open_thinking_by_index: + self._open_thinking_by_index.pop(index, None) + return + if index in self._open_text_by_index: + self._open_text_by_index.pop(index, None) + + +def _event_index(event: dict[str, Any]) -> int: + return int(event.get("index", -1)) + + +def _event_tool_id(event: dict[str, Any], key: str) -> str: + return str(event.get(key, "") or "").strip() diff --git a/src/free_claude_code/messaging/transcript/context.py b/src/free_claude_code/messaging/transcript/context.py new file mode 100644 index 0000000..a88d3c3 --- /dev/null +++ b/src/free_claude_code/messaging/transcript/context.py @@ -0,0 +1,18 @@ +"""Rendering context used by transcript segments.""" + +from collections.abc import Callable +from dataclasses import dataclass + + +@dataclass +class RenderCtx: + bold: Callable[[str], str] + code_inline: Callable[[str], str] + escape_code: Callable[[str], str] + escape_text: Callable[[str], str] + render_markdown: Callable[[str], str] + + thinking_tail_max: int | None = 1000 + tool_input_tail_max: int | None = 1200 + tool_output_tail_max: int | None = 1600 + text_tail_max: int | None = 2000 diff --git a/src/free_claude_code/messaging/transcript/renderer.py b/src/free_claude_code/messaging/transcript/renderer.py new file mode 100644 index 0000000..7a1eccb --- /dev/null +++ b/src/free_claude_code/messaging/transcript/renderer.py @@ -0,0 +1,65 @@ +"""Render and truncate ordered transcript segments.""" + +from collections import deque +from collections.abc import Iterable + +from .context import RenderCtx +from .segments import Segment + + +def render_segments( + segments: Iterable[Segment], + ctx: RenderCtx, + *, + limit_chars: int, + status: str | None, +) -> str: + rendered: list[str] = [] + for segment in segments: + try: + output = segment.render(ctx) + except Exception: + continue + if output: + rendered.append(output) + + status_text = f"\n\n{status}" if status else "" + prefix_marker = ctx.escape_text("... (truncated)\n") + + def _join(parts: Iterable[str], add_marker: bool) -> str: + body = "\n".join(parts) + if add_marker and body: + body = prefix_marker + body + return body + status_text if (body or status_text) else status_text + + candidate = _join(rendered, add_marker=False) + if len(candidate) <= limit_chars: + return candidate + + parts: deque[str] = deque(rendered) + dropped = False + last_part: str | None = None + while parts: + candidate = _join(parts, add_marker=True) + if len(candidate) <= limit_chars: + return candidate + last_part = parts.popleft() + dropped = True + + if dropped and last_part: + budget = limit_chars - len(prefix_marker) - len(status_text) + if budget > 20: + tail = ( + "..." + last_part[-(budget - 3) :] + if len(last_part) > budget + else last_part + ) + candidate = prefix_marker + tail + status_text + if len(candidate) <= limit_chars: + return candidate + + if dropped: + minimal = prefix_marker + status_text.lstrip("\n") + if len(minimal) <= limit_chars: + return minimal + return status or "" diff --git a/src/free_claude_code/messaging/transcript/segments.py b/src/free_claude_code/messaging/transcript/segments.py new file mode 100644 index 0000000..783ee5a --- /dev/null +++ b/src/free_claude_code/messaging/transcript/segments.py @@ -0,0 +1,176 @@ +"""Transcript segment types for messaging UI output.""" + +import json +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + +from .context import RenderCtx + + +def safe_json_dumps(obj: Any) -> str: + try: + return json.dumps(obj, indent=2, ensure_ascii=False, sort_keys=True) + except Exception: + return str(obj) + + +@dataclass +class Segment(ABC): + kind: str + + @abstractmethod + def render(self, ctx: RenderCtx) -> str: ... + + +@dataclass +class ThinkingSegment(Segment): + def __init__(self) -> None: + super().__init__(kind="thinking") + self._parts: list[str] = [] + + def append(self, text: str) -> None: + if text: + self._parts.append(text) + + @property + def text(self) -> str: + return "".join(self._parts) + + def render(self, ctx: RenderCtx) -> str: + raw = self.text or "" + if ctx.thinking_tail_max is not None and len(raw) > ctx.thinking_tail_max: + raw = "..." + raw[-(ctx.thinking_tail_max - 3) :] + inner = ctx.escape_code(raw) + return f"💭 {ctx.bold('Thinking')}\n```\n{inner}\n```" + + +@dataclass +class TextSegment(Segment): + def __init__(self) -> None: + super().__init__(kind="text") + self._parts: list[str] = [] + + def append(self, text: str) -> None: + if text: + self._parts.append(text) + + @property + def text(self) -> str: + return "".join(self._parts) + + def render(self, ctx: RenderCtx) -> str: + raw = self.text or "" + if ctx.text_tail_max is not None and len(raw) > ctx.text_tail_max: + raw = "..." + raw[-(ctx.text_tail_max - 3) :] + return ctx.render_markdown(raw) + + +@dataclass +class ToolCallSegment(Segment): + tool_use_id: str + name: str + closed: bool = False + indent_level: int = 0 + + def __init__(self, tool_use_id: str, name: str, *, indent_level: int = 0) -> None: + super().__init__(kind="tool_call") + self.tool_use_id = str(tool_use_id or "") + self.name = str(name or "tool") + self.closed = False + self.indent_level = max(0, int(indent_level)) + + def render(self, ctx: RenderCtx) -> str: + name = ctx.code_inline(self.name) + prefix = " " * self.indent_level + return f"{prefix}🛠 {ctx.bold('Tool call:')} {name}" + + +@dataclass +class ToolResultSegment(Segment): + tool_use_id: str + name: str | None + content_text: str + is_error: bool = False + + def __init__( + self, + tool_use_id: str, + content: Any, + *, + name: str | None = None, + is_error: bool = False, + ) -> None: + super().__init__(kind="tool_result") + self.tool_use_id = str(tool_use_id or "") + self.name = str(name) if name is not None else None + self.is_error = bool(is_error) + self.content_text = ( + content if isinstance(content, str) else safe_json_dumps(content) + ) + + def render(self, ctx: RenderCtx) -> str: + raw = self.content_text or "" + if ctx.tool_output_tail_max is not None and len(raw) > ctx.tool_output_tail_max: + raw = "..." + raw[-(ctx.tool_output_tail_max - 3) :] + inner = ctx.escape_code(raw) + label = "Tool error:" if self.is_error else "Tool result:" + maybe_name = f" {ctx.code_inline(self.name)}" if self.name else "" + return f"📤 {ctx.bold(label)}{maybe_name}\n```\n{inner}\n```" + + +@dataclass +class SubagentSegment(Segment): + description: str + tool_calls: int = 0 + tools_used: set[str] = field(default_factory=set) + current_tool: ToolCallSegment | None = None + + def __init__(self, description: str) -> None: + super().__init__(kind="subagent") + self.description = str(description or "Subagent") + self.tool_calls = 0 + self.tools_used = set() + self.current_tool = None + + def set_current_tool_call(self, tool_use_id: str, name: str) -> ToolCallSegment: + tool_use_id = str(tool_use_id or "") + name = str(name or "tool") + self.tools_used.add(name) + self.tool_calls += 1 + self.current_tool = ToolCallSegment(tool_use_id, name, indent_level=1) + return self.current_tool + + def render(self, ctx: RenderCtx) -> str: + inner_prefix = " " + lines = [f"🤖 {ctx.bold('Subagent:')} {ctx.code_inline(self.description)}"] + + if self.current_tool is not None: + try: + rendered = self.current_tool.render(ctx) + except Exception: + rendered = "" + if rendered: + lines.append(rendered) + + tools_used = sorted(self.tools_used) + tools_set_raw = "{{{}}}".format(", ".join(tools_used)) if tools_used else "{}" + lines.append( + f"{inner_prefix}{ctx.bold('Tools used:')} {ctx.code_inline(tools_set_raw)}" + ) + lines.append( + f"{inner_prefix}{ctx.bold('Tool calls:')} {ctx.code_inline(str(self.tool_calls))}" + ) + return "\n".join(lines) + + +@dataclass +class ErrorSegment(Segment): + message: str + + def __init__(self, message: str) -> None: + super().__init__(kind="error") + self.message = str(message or "Unknown error") + + def render(self, ctx: RenderCtx) -> str: + return f"⚠️ {ctx.bold('Error:')} {ctx.code_inline(self.message)}" diff --git a/src/free_claude_code/messaging/transcript/subagents.py b/src/free_claude_code/messaging/transcript/subagents.py new file mode 100644 index 0000000..6cbb2c0 --- /dev/null +++ b/src/free_claude_code/messaging/transcript/subagents.py @@ -0,0 +1,108 @@ +"""Task/subagent display state for messaging transcripts.""" + +from typing import Any + +from loguru import logger + +from .segments import SubagentSegment + + +class SubagentState: + """Track active Task tool calls that suppress nested text/thinking output.""" + + def __init__(self, *, debug: bool = False) -> None: + self._stack: list[str] = [] + self._segments: list[SubagentSegment] = [] + self._debug = debug + + @property + def open_ids(self) -> tuple[str, ...]: + return tuple(self._stack) + + def in_subagent(self) -> bool: + return bool(self._stack) + + def current_segment(self) -> SubagentSegment | None: + return self._segments[-1] if self._segments else None + + def push(self, tool_id: str, segment: SubagentSegment) -> None: + marker = str(tool_id or "").strip() or f"__task_{len(self._stack) + 1}" + self._stack.append(marker) + self._segments.append(segment) + if self._debug: + logger.debug( + "SUBAGENT_STACK: push id=%r depth=%d heading=%r", + marker, + len(self._stack), + segment.description, + ) + + def close_for_tool_result(self, tool_id: str, *, tool_name: str | None) -> bool: + tool_id = str(tool_id or "").strip() + popped = self._pop(tool_id) + top = self._stack[-1] if self._stack else "" + looks_like_task_id = "task" in tool_id.lower() + + if ( + not popped + and tool_id + and top.startswith("__task_") + and tool_name in (None, "Task") + and looks_like_task_id + ): + return self._pop("") + return popped + + def _pop(self, tool_id: str) -> bool: + tool_id = str(tool_id or "").strip() + if not self._stack: + return False + + if tool_id: + if _ids_roughly_match(self._stack[-1], tool_id): + self._pop_to_depth(len(self._stack) - 1, tool_id, "LIFO") + return True + + for idx in range(len(self._stack) - 1, -1, -1): + if _ids_roughly_match(self._stack[idx], tool_id): + self._pop_to_depth(idx, tool_id, "matched") + return True + return False + + if self._stack[-1].startswith("__task_"): + self._pop_to_depth(len(self._stack) - 1, self._stack[-1], "synthetic") + return True + return False + + def _pop_to_depth(self, idx: int, requested_id: str, reason: str) -> None: + while len(self._stack) > idx: + popped = self._stack.pop() + if self._segments: + self._segments.pop() + if self._debug: + logger.debug( + "SUBAGENT_STACK: pop id=%r depth=%d (%s=%r)", + popped, + len(self._stack), + reason, + requested_id, + ) + + +def task_heading_from_input(input_value: Any) -> str: + if isinstance(input_value, dict): + for key in ("description", "subagent_type", "type"): + value = str(input_value.get(key, "") or "").strip() + if value: + return value + return "Subagent" + + +def _ids_roughly_match(stack_id: str, result_id: str) -> bool: + if not stack_id or not result_id: + return False + return ( + stack_id == result_id + or stack_id.startswith(result_id) + or result_id.startswith(stack_id) + ) diff --git a/src/free_claude_code/messaging/transcription.py b/src/free_claude_code/messaging/transcription.py new file mode 100644 index 0000000..82eb426 --- /dev/null +++ b/src/free_claude_code/messaging/transcription.py @@ -0,0 +1,132 @@ +"""Instance-owned local Whisper transcription.""" + +import asyncio +from pathlib import Path +from typing import Any + +from loguru import logger + +_MODEL_MAP: dict[str, str] = { + "tiny": "openai/whisper-tiny", + "base": "openai/whisper-base", + "small": "openai/whisper-small", + "medium": "openai/whisper-medium", + "large-v2": "openai/whisper-large-v2", + "large-v3": "openai/whisper-large-v3", + "large-v3-turbo": "openai/whisper-large-v3-turbo", +} +_WHISPER_SAMPLE_RATE = 16000 + + +class TranscriptionService: + """Own one lazily loaded local Whisper pipeline.""" + + def __init__( + self, + *, + model: str, + device: str, + huggingface_api_key: str = "", + ) -> None: + if device not in {"cpu", "cuda"}: + raise ValueError( + f"Local Whisper device must be 'cpu' or 'cuda', got {device!r}" + ) + self._model_id = _MODEL_MAP.get(model, model) + self._device = device + self._huggingface_api_key = huggingface_api_key + self._pipeline: Any | None = None + self._lock = asyncio.Lock() + self._closed = False + + async def transcribe(self, file_path: Path) -> str: + """Transcribe one audio file without blocking the event loop.""" + async with self._lock: + if self._closed: + raise RuntimeError("Transcription service is closed.") + worker = asyncio.create_task( + asyncio.to_thread(self._transcribe_sync, file_path) + ) + try: + return await asyncio.shield(worker) + except asyncio.CancelledError: + await _wait_for_thread_exit(worker) + raise + + async def close(self) -> None: + """Prevent new work and release the owned model pipeline.""" + self._closed = True + async with self._lock: + self._pipeline = None + self._huggingface_api_key = "" + + def _transcribe_sync(self, file_path: Path) -> str: + pipe = self._get_pipeline() + audio = _load_audio(file_path) + result = pipe(audio, generate_kwargs={"language": "en", "task": "transcribe"}) + text = result.get("text", "") or "" + if isinstance(text, list): + text = " ".join(text) if text else "" + result_text = text.strip() + logger.debug("Local transcription: {} chars", len(result_text)) + return result_text or "(no speech detected)" + + def _get_pipeline(self) -> Any: + if self._pipeline is not None: + return self._pipeline + try: + import torch + from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline + except ImportError as exc: + raise ImportError( + "Local Whisper requires the voice_local extra. " + "Install with: uv sync --extra voice_local" + ) from exc + + token = self._huggingface_api_key or None + use_cuda = self._device == "cuda" and torch.cuda.is_available() + pipeline_device = "cuda:0" if use_cuda else "cpu" + model_dtype = torch.float16 if use_cuda else torch.float32 + model = AutoModelForSpeechSeq2Seq.from_pretrained( + self._model_id, + dtype=model_dtype, + low_cpu_mem_usage=True, + attn_implementation="sdpa", + token=token, + ) + model = model.to(pipeline_device) + processor = AutoProcessor.from_pretrained(self._model_id, token=token) + self._pipeline = pipeline( + "automatic-speech-recognition", + model=model, + tokenizer=processor.tokenizer, + feature_extractor=processor.feature_extractor, + device=pipeline_device, + ) + logger.debug( + "Loaded Whisper pipeline: model={} device={}", + self._model_id, + pipeline_device, + ) + return self._pipeline + + +async def _wait_for_thread_exit(worker: asyncio.Task[str]) -> None: + """Wait through repeated caller cancellation without cancelling thread work.""" + while not worker.done(): + try: + await asyncio.shield(asyncio.wait((worker,))) + except asyncio.CancelledError: + continue + if not worker.cancelled(): + worker.exception() + + +def _load_audio(file_path: Path) -> dict[str, Any]: + """Load an audio file into the waveform shape expected by Whisper.""" + import librosa + + waveform, sample_rate = librosa.load( + str(file_path), sr=_WHISPER_SAMPLE_RATE, mono=True + ) + return {"array": waveform, "sampling_rate": sample_rate} diff --git a/src/free_claude_code/messaging/trees/__init__.py b/src/free_claude_code/messaging/trees/__init__.py new file mode 100644 index 0000000..318776f --- /dev/null +++ b/src/free_claude_code/messaging/trees/__init__.py @@ -0,0 +1,41 @@ +"""Internal messaging tree package facade.""" + +from .identity import TreeIdentity +from .manager import TreeQueueManager +from .node import MessageReferenceKind, MessageState +from .snapshot import ConversationSnapshot, TreeSnapshot +from .transitions import ( + AdmissionRejection, + CancellationReason, + CancellationResult, + CancellationUiOwner, + FailureResult, + MessageSubtreeRemovalResult, + NodeClaim, + NodeUiTarget, + NodeView, + QueueDecision, + QueueEntry, + ReplyTarget, +) + +__all__ = [ + "AdmissionRejection", + "CancellationReason", + "CancellationResult", + "CancellationUiOwner", + "ConversationSnapshot", + "FailureResult", + "MessageReferenceKind", + "MessageState", + "MessageSubtreeRemovalResult", + "NodeClaim", + "NodeUiTarget", + "NodeView", + "QueueDecision", + "QueueEntry", + "ReplyTarget", + "TreeIdentity", + "TreeQueueManager", + "TreeSnapshot", +] diff --git a/src/free_claude_code/messaging/trees/graph.py b/src/free_claude_code/messaging/trees/graph.py new file mode 100644 index 0000000..d120df0 --- /dev/null +++ b/src/free_claude_code/messaging/trees/graph.py @@ -0,0 +1,250 @@ +"""In-memory graph for one messaging conversation tree.""" + +from loguru import logger + +from ..models import MessageScope +from .identity import TreeIdentity +from .node import MessageNode, MessageReferenceKind, MessageState +from .snapshot import ( + TreeSnapshot, + node_from_snapshot, + node_scope_from_snapshot, + node_to_snapshot, +) + + +class MessageTreeGraph: + """Own parent/child links, node lookup, and status-message lookup.""" + + def __init__(self, root_node: MessageNode) -> None: + if root_node.status_message_id == root_node.node_id: + raise ValueError("Prompt and status message IDs must be distinct") + self.root_id = root_node.node_id + self.identity = TreeIdentity( + scope=root_node.scope, + root_id=root_node.node_id, + ) + self._nodes: dict[str, MessageNode] = {root_node.node_id: root_node} + self._status_to_node: dict[str, str] = {} + if root_node.status_message_id is not None: + self._status_to_node[root_node.status_message_id] = root_node.node_id + + def add_node( + self, + *, + node_id: str, + scope: MessageScope, + prompt: str, + status_message_id: str, + parent_id: str, + parent_reference_id: str, + ) -> MessageNode: + if scope != self.identity.scope: + raise ValueError("A reply cannot cross platform chat boundaries") + if parent_id not in self._nodes: + raise ValueError(f"Parent node {parent_id} not found in tree") + parent_reference = self.resolve_reference(parent_reference_id) + if parent_reference is None or parent_reference[0].node_id != parent_id: + raise ValueError("Reply reference does not belong to its logical parent") + if node_id in self._nodes: + raise ValueError(f"Node {node_id} already exists in tree") + if status_message_id == node_id: + raise ValueError("Prompt and status message IDs must be distinct") + if node_id in self._status_to_node: + raise ValueError(f"Message reference {node_id} already exists in tree") + if status_message_id in self._status_to_node: + raise ValueError( + f"Status message {status_message_id} already exists in tree" + ) + if status_message_id in self._nodes: + raise ValueError( + f"Message reference {status_message_id} already exists in tree" + ) + + node = MessageNode( + node_id=node_id, + scope=scope, + prompt=prompt, + status_message_id=status_message_id, + parent_id=parent_id, + parent_reference_id=parent_reference_id, + state=MessageState.PENDING, + ) + self._nodes[node_id] = node + self._status_to_node[status_message_id] = node_id + self._nodes[parent_id].children_ids.append(node_id) + logger.debug("Added node {} as child of {}", node_id, parent_id) + return node + + def get_node(self, node_id: str) -> MessageNode | None: + return self._nodes.get(node_id) + + def get_root(self) -> MessageNode: + return self._nodes[self.root_id] + + def get_parent(self, node_id: str) -> MessageNode | None: + node = self._nodes.get(node_id) + if not node or not node.parent_id: + return None + return self._nodes.get(node.parent_id) + + def get_parent_session_id(self, node_id: str) -> str | None: + parent = self.get_parent(node_id) + return parent.session_id if parent else None + + def find_node_by_status_message(self, status_msg_id: str) -> MessageNode | None: + node_id = self._status_to_node.get(status_msg_id) + return self._nodes.get(node_id) if node_id else None + + def resolve_reference( + self, reference_id: str + ) -> tuple[MessageNode, MessageReferenceKind] | None: + """Resolve an exact prompt or FCC status reference.""" + node = self._nodes.get(reference_id) + if node is not None: + return node, MessageReferenceKind.PROMPT + node = self.find_node_by_status_message(reference_id) + if node is not None: + return node, MessageReferenceKind.STATUS + return None + + def all_nodes(self) -> list[MessageNode]: + return list(self._nodes.values()) + + def get_descendants(self, node_id: str) -> list[str]: + if node_id not in self._nodes: + return [] + result: list[str] = [] + stack = [node_id] + while stack: + current_id = stack.pop() + result.append(current_id) + node = self._nodes.get(current_id) + if node: + stack.extend(node.children_ids) + return result + + def get_reference_descendants(self, reference_id: str) -> list[str]: + """Return the literal platform reply subtree rooted at a reference.""" + if self.resolve_reference(reference_id) is None: + return [] + + children: dict[str, list[str]] = {} + for node in self._nodes.values(): + if node.status_message_id is not None: + children.setdefault(node.node_id, []).append(node.status_message_id) + if node.parent_reference_id is not None: + children.setdefault(node.parent_reference_id, []).append(node.node_id) + + result: list[str] = [] + stack = [reference_id] + while stack: + current_id = stack.pop() + result.append(current_id) + stack.extend(children.get(current_id, ())) + return result + + def remove_nodes(self, node_ids: set[str]) -> None: + """Remove an exact set of nodes after reference-subtree calculation.""" + for node_id in node_ids: + node = self._nodes.get(node_id) + if node is None: + continue + if node.parent_id is not None: + parent = self._nodes.get(node.parent_id) + if parent is not None: + parent.children_ids = [ + child_id + for child_id in parent.children_ids + if child_id != node_id + ] + if node.status_message_id is not None: + self._status_to_node.pop(node.status_message_id, None) + self._nodes.pop(node_id, None) + + def clear_status(self, node_id: str) -> None: + """Remove one status reference while preserving its prompt node.""" + node = self._nodes.get(node_id) + if node is None or node.status_message_id is None: + return + self._status_to_node.pop(node.status_message_id, None) + node.clear_status() + + def all_reference_ids(self) -> set[str]: + """Return every prompt and live FCC status reference in the tree.""" + references = set(self._nodes) + references.update(self._status_to_node) + return references + + def snapshot(self) -> TreeSnapshot: + return TreeSnapshot( + scope=self.identity.scope, + root_id=self.root_id, + nodes={ + node_id: node_to_snapshot(node) for node_id, node in self._nodes.items() + }, + ) + + @classmethod + def from_snapshot(cls, snapshot: TreeSnapshot) -> MessageTreeGraph: + root_data = snapshot.nodes[snapshot.root_id] + if not isinstance(root_data, dict): + raise ValueError("Tree snapshot contains an invalid root node") + if node_scope_from_snapshot(root_data) not in (None, snapshot.scope): + raise ValueError("Tree snapshot contains a cross-scope node") + root_node = node_from_snapshot(root_data, snapshot.scope) + if root_node.node_id != snapshot.root_id: + raise ValueError("Tree snapshot root key does not match its node ID") + graph = cls(root_node) + reference_owner = {root_node.node_id: root_node.node_id} + if root_node.status_message_id is not None: + reference_owner[root_node.status_message_id] = root_node.node_id + for snapshot_node_id, node_data in snapshot.nodes.items(): + if snapshot_node_id == snapshot.root_id: + continue + if not isinstance(node_data, dict): + raise ValueError("Tree snapshot contains an invalid node") + if node_scope_from_snapshot(node_data) not in (None, snapshot.scope): + raise ValueError("Tree snapshot contains a cross-scope node") + node = node_from_snapshot(node_data, snapshot.scope) + if str(snapshot_node_id) != node.node_id: + raise ValueError("Tree snapshot node key does not match its node ID") + if node.status_message_id == node.node_id: + raise ValueError("Prompt and status message IDs must be distinct") + if node.node_id in graph._nodes: + raise ValueError(f"Duplicate node {node.node_id} in tree snapshot") + references = {node.node_id} + if node.status_message_id is not None: + references.add(node.status_message_id) + for reference in references: + owner = reference_owner.get(reference) + if owner is not None and owner != node.node_id: + raise ValueError( + f"Duplicate message reference {reference} in tree snapshot" + ) + reference_owner[reference] = node.node_id + graph._nodes[node.node_id] = node + if node.status_message_id is not None: + graph._status_to_node[node.status_message_id] = node.node_id + + if root_node.parent_id is not None or root_node.parent_reference_id is not None: + raise ValueError("Tree snapshot root cannot have a parent") + for node in graph._nodes.values(): + if node.node_id == graph.root_id: + continue + if node.parent_id is None or node.parent_id not in graph._nodes: + raise ValueError(f"Node {node.node_id} has no valid parent") + if node.parent_reference_id is None: + raise ValueError(f"Node {node.node_id} has no exact parent reference") + parent_reference = graph.resolve_reference(node.parent_reference_id) + if ( + parent_reference is None + or parent_reference[0].node_id != node.parent_id + ): + raise ValueError( + f"Node {node.node_id} has an invalid exact parent reference" + ) + graph._nodes[node.parent_id].children_ids.append(node.node_id) + if set(graph.get_descendants(graph.root_id)) != set(graph._nodes): + raise ValueError("Tree snapshot contains a disconnected branch") + return graph diff --git a/src/free_claude_code/messaging/trees/identity.py b/src/free_claude_code/messaging/trees/identity.py new file mode 100644 index 0000000..ada6bb3 --- /dev/null +++ b/src/free_claude_code/messaging/trees/identity.py @@ -0,0 +1,16 @@ +"""Stable and runtime identities for messaging conversation trees.""" + +from dataclasses import dataclass + +from ..models import MessageScope + + +@dataclass(frozen=True, slots=True) +class TreeIdentity: + """Customer conversation identity, independent of one runtime generation.""" + + scope: MessageScope + root_id: str + + +__all__ = ["TreeIdentity"] diff --git a/src/free_claude_code/messaging/trees/manager.py b/src/free_claude_code/messaging/trees/manager.py new file mode 100644 index 0000000..472055a --- /dev/null +++ b/src/free_claude_code/messaging/trees/manager.py @@ -0,0 +1,587 @@ +"""Public facade for atomic messaging tree aggregates.""" + +import asyncio +from collections.abc import Callable, Coroutine +from typing import Any + +from loguru import logger + +from ..models import IncomingMessage, MessageScope +from .identity import TreeIdentity +from .node import MessageNode, MessageState +from .processor import ( + CancelledTask, + NodeProcessor, + NodeStartedCallback, + QueueUpdateCallback, + TreeQueueProcessor, +) +from .repository import TreeRepository +from .runtime import MessageTree +from .snapshot import ConversationSnapshot, TreeSnapshot +from .transitions import ( + AdmissionRejection, + CancellationEffect, + CancellationReason, + CancellationResult, + CancellationUiOwner, + FailureResult, + MessageSubtreeRemovalResult, + NodeClaim, + NodeUiTarget, + NodeView, + QueueDecision, + ReplyTarget, + TreeCancellation, +) + +CANCEL_TASK_DRAIN_TIMEOUT_S = 5.0 + + +async def _finish_transition[T](awaitable: Coroutine[Any, Any, T]) -> T: + """Deliver a transition result even if its caller is cancelled mid-commit.""" + task = asyncio.create_task(awaitable) + current = asyncio.current_task() + while True: + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + if task.done(): + return task.result() + if current is not None: + while current.cancelling(): + current.uncancel() + + +async def _drain_cancelled_tasks(tasks: list[asyncio.Task[None]]) -> None: + """Wait briefly for cancelled claims to finish aggregate cleanup.""" + if not tasks: + return + done, pending = await asyncio.wait( + set(tasks), + timeout=CANCEL_TASK_DRAIN_TIMEOUT_S, + ) + if pending: + logger.warning( + "Timed out waiting for {} cancelled messaging task(s) to finish cleanup", + len(pending), + ) + for task in done: + if task.cancelled(): + continue + try: + task.result() + except Exception as exc: + logger.debug( + "Cancelled messaging task finished with {}", type(exc).__name__ + ) + + +class TreeQueueManager: + """Locate aggregates and coordinate tasks without exposing mutable trees.""" + + def __init__( + self, + node_processor: NodeProcessor, + *, + queue_update_callback: QueueUpdateCallback | None = None, + node_started_callback: NodeStartedCallback | None = None, + unexpected_failure_callback: Callable[[FailureResult], None] | None = None, + log_messaging_error_details: bool = False, + _repository: TreeRepository | None = None, + _restored_snapshot: ConversationSnapshot | None = None, + _restored_stale_targets: tuple[NodeUiTarget, ...] = (), + ) -> None: + self._repository = _repository or TreeRepository() + self._lock = asyncio.Lock() + self._processor = TreeQueueProcessor( + node_processor, + claim_failure_callback=self._handle_processor_failure, + claim_finished_callback=self._finish_claim, + queue_update_callback=queue_update_callback, + node_started_callback=node_started_callback, + log_messaging_error_details=log_messaging_error_details, + ) + self._restored_snapshot = _restored_snapshot + self._restored_stale_targets = _restored_stale_targets + self._unexpected_failure_callback = unexpected_failure_callback + logger.info("TreeQueueManager initialized") + + @property + def restored_snapshot(self) -> ConversationSnapshot | None: + return self._restored_snapshot + + @property + def restored_stale_targets(self) -> tuple[NodeUiTarget, ...]: + return self._restored_stale_targets + + async def admit( + self, + incoming: IncomingMessage, + status_message_id: str, + *, + parent_reference_id: str | None = None, + ) -> QueueDecision: + """Publish one admission before its processor can begin.""" + node_id = str(incoming.message_id) + scope = incoming.scope + prompt = incoming.text or "" + async with self._lock: + duplicate_tree = self._repository.get_tree_for_reference(scope, node_id) + if duplicate_tree is None: + duplicate_tree = self._repository.get_tree_for_reference( + scope, + status_message_id, + ) + if duplicate_tree is not None: + return await duplicate_tree.enqueue_or_claim(node_id) + + tree: MessageTree | None = None + resolved_parent: ReplyTarget | None = None + if parent_reference_id is not None: + tree = self._repository.get_tree_for_reference( + scope, parent_reference_id + ) + if tree is not None: + resolved_parent = await tree.resolve_reply(parent_reference_id) + + if tree is None or resolved_parent is None: + return QueueDecision( + claim=None, + position=None, + snapshot=None, + rejection=AdmissionRejection.PARENT_REMOVED, + ) + + if tree is not None and resolved_parent is not None: + decision = await tree.add_and_enqueue( + node_id, + scope, + prompt, + status_message_id, + resolved_parent.node_id, + resolved_parent.reference_id, + ) + self._repository.register_node( + identity=tree.identity, + node_id=node_id, + status_message_id=status_message_id, + ) + logger.info("Added node {} to tree {}", node_id, tree.identity) + else: + root = MessageNode( + node_id=node_id, + scope=scope, + prompt=prompt, + status_message_id=status_message_id, + state=MessageState.PENDING, + ) + tree = MessageTree(root) + decision = await tree.enqueue_or_claim(node_id) + self._repository.add_tree( + tree, + node_id=node_id, + status_message_id=status_message_id, + ) + logger.info("Created new tree {}", tree.identity) + + if decision.claim is not None: + self._processor.launch(tree, decision.claim) + return decision + + async def resolve_reply( + self, + scope: MessageScope, + reference_id: str, + ) -> ReplyTarget | None: + """Resolve a scoped node or status-message reference.""" + async with self._lock: + tree = self._repository.get_tree_for_reference(scope, reference_id) + return await tree.resolve_reply(reference_id) if tree is not None else None + + async def resolve_node_id( + self, + scope: MessageScope, + reference_id: str, + ) -> str | None: + target = await self.resolve_reply(scope, reference_id) + return target.node_id if target is not None else None + + async def get_node( + self, + scope: MessageScope, + reference_id: str, + ) -> NodeView | None: + """Return an immutable node read model.""" + async with self._lock: + tree = self._repository.get_tree_for_reference(scope, reference_id) + if tree is None: + return None + target = await tree.resolve_reply(reference_id) + return await tree.node_view(target.node_id) if target is not None else None + + async def record_session( + self, + claim: NodeClaim, + session_id: str, + ) -> TreeSnapshot | None: + async with self._lock: + tree = self._repository.get_tree(claim.identity) + return ( + await tree.record_session(claim.claim_id, session_id) + if tree is not None + else None + ) + + async def complete_claim( + self, + claim: NodeClaim, + session_id: str | None, + ) -> TreeSnapshot | None: + async with self._lock: + tree = self._repository.get_tree(claim.identity) + return ( + await tree.complete_claim(claim.claim_id, session_id) + if tree is not None + else None + ) + + async def fail_claim( + self, + claim: NodeClaim, + *, + propagate: bool = True, + ) -> FailureResult: + return await self._fail_claim(claim, propagate=propagate) + + async def _fail_claim( + self, + claim: NodeClaim, + *, + propagate: bool, + ) -> FailureResult: + async with self._lock: + tree = self._repository.get_tree(claim.identity) + result = ( + await tree.fail_claim( + claim.claim_id, + propagate=propagate, + ) + if tree is not None + else FailureResult(affected=(), queue_update=None, snapshot=None) + ) + if result.queue_update is not None: + await self._processor.notify_queue_updated(result.queue_update) + return result + + async def _handle_processor_failure( + self, + claim: NodeClaim, + ) -> None: + """Route an escaped runner failure through manager-owned effects.""" + result = await self._fail_claim(claim, propagate=True) + if self._unexpected_failure_callback is None: + return + try: + self._unexpected_failure_callback(result) + except Exception as exc: + logger.warning( + "Unexpected messaging failure callback failed: {}", + type(exc).__name__, + ) + + async def _finish_claim(self, tree: MessageTree, claim: NodeClaim) -> None: + """Serialize successor task publication with aggregate detachment.""" + async with self._lock: + tree_is_published = self._repository.get_tree(claim.identity) is tree + completion = await tree.finish_and_claim_next(claim.claim_id) + if tree_is_published and completion.next_claim is not None: + self._processor.launch( + tree, + completion.next_claim, + announce_started=True, + queue=completion.queue, + ) + + @staticmethod + def _external_effects( + transition: TreeCancellation, + cancelled_task: CancelledTask | None, + ) -> tuple[CancellationEffect, ...]: + active_node_id = ( + transition.active_claim.node.node_id + if transition.active_claim is not None + else None + ) + return tuple( + CancellationEffect( + node=node, + ui_owner=( + CancellationUiOwner.RUNNER + if node.node_id == active_node_id + and cancelled_task is not None + and cancelled_task.runner_started + else CancellationUiOwner.WORKFLOW + ), + ) + for node in transition.nodes + ) + + async def _current_snapshot( + self, + identity: TreeIdentity, + expected_tree: MessageTree, + ) -> TreeSnapshot | None: + async with self._lock: + tree = self._repository.get_tree(identity) + if tree is not expected_tree: + return None + return await tree.snapshot() + + async def cancel_node( + self, + scope: MessageScope, + node_id: str, + *, + reason: CancellationReason | None = None, + ) -> CancellationResult: + return await _finish_transition(self._cancel_node(scope, node_id, reason)) + + async def _cancel_node( + self, + scope: MessageScope, + node_id: str, + reason: CancellationReason | None, + ) -> CancellationResult: + async with self._lock: + tree = self._repository.get_tree_for_reference(scope, node_id) + if tree is None: + return CancellationResult() + target = await tree.resolve_reply(node_id) + if target is None: + return CancellationResult() + transition = await tree.cancel_node(target.node_id) + cancelled_task = ( + self._processor.cancel(transition.active_claim, reason) + if transition.active_claim is not None + else None + ) + + if transition.queue_update is not None: + await self._processor.notify_queue_updated(transition.queue_update) + if cancelled_task is not None: + await _drain_cancelled_tasks([cancelled_task.task]) + snapshot = await self._current_snapshot(tree.identity, tree) + return CancellationResult( + effects=self._external_effects(transition, cancelled_task), + snapshots=(snapshot,) if snapshot is not None else (), + ) + + async def cancel_all( + self, + *, + reason: CancellationReason | None = None, + ) -> CancellationResult: + return await _finish_transition(self._cancel_all(reason)) + + async def _cancel_all( + self, + reason: CancellationReason | None, + ) -> CancellationResult: + transitions: list[ + tuple[MessageTree, TreeCancellation, CancelledTask | None] + ] = [] + async with self._lock: + for tree in self._repository.trees(): + transition = await tree.cancel_all() + cancelled_task = ( + self._processor.cancel(transition.active_claim, reason) + if transition.active_claim is not None + else None + ) + transitions.append((tree, transition, cancelled_task)) + + for _tree, transition, _task in transitions: + if transition.queue_update is not None: + await self._processor.notify_queue_updated(transition.queue_update) + await _drain_cancelled_tasks( + [ + cancelled.task + for _tree, _transition, cancelled in transitions + if cancelled is not None + ] + ) + + effects: list[CancellationEffect] = [] + snapshots: list[TreeSnapshot] = [] + for tree, transition, cancelled_task in transitions: + effects.extend(self._external_effects(transition, cancelled_task)) + snapshot = await self._current_snapshot(tree.identity, tree) + if snapshot is not None: + snapshots.append(snapshot) + return CancellationResult(effects=tuple(effects), snapshots=tuple(snapshots)) + + async def clear_scope( + self, + scope: MessageScope, + *, + reason: CancellationReason | None = None, + ) -> CancellationResult: + """Atomically detach one chat's trees, then drain their active tasks.""" + return await _finish_transition(self._clear_scope(scope, reason)) + + async def _clear_scope( + self, + scope: MessageScope, + reason: CancellationReason | None, + ) -> CancellationResult: + transitions: list[tuple[TreeCancellation, CancelledTask | None]] = [] + async with self._lock: + for tree in self._repository.trees(): + if tree.identity.scope != scope: + continue + transition = await tree.cancel_all() + cancelled_task = ( + self._processor.cancel(transition.active_claim, reason) + if transition.active_claim is not None + else None + ) + transitions.append((transition, cancelled_task)) + self._repository.remove_tree(tree.identity) + + await _drain_cancelled_tasks( + [ + cancelled.task + for _transition, cancelled in transitions + if cancelled is not None + ] + ) + effects: list[CancellationEffect] = [] + for transition, cancelled_task in transitions: + effects.extend(self._external_effects(transition, cancelled_task)) + return CancellationResult(effects=tuple(effects)) + + async def remove_message_subtree( + self, + scope: MessageScope, + reference_id: str, + *, + reason: CancellationReason | None = None, + ) -> MessageSubtreeRemovalResult: + """Atomically cancel, detach, and unindex one literal reply subtree.""" + return await _finish_transition( + self._remove_message_subtree(scope, reference_id, reason) + ) + + async def _remove_message_subtree( + self, + scope: MessageScope, + reference_id: str, + reason: CancellationReason | None, + ) -> MessageSubtreeRemovalResult: + async with self._lock: + tree = self._repository.get_tree_for_reference(scope, reference_id) + if tree is None: + return MessageSubtreeRemovalResult( + cancellation=CancellationResult(), + removed_tree_identity=None, + delete_message_ids=frozenset(), + tree_matched=False, + ) + target = await tree.resolve_reply(reference_id) + if target is None: + return MessageSubtreeRemovalResult( + cancellation=CancellationResult(), + removed_tree_identity=None, + delete_message_ids=frozenset(), + tree_matched=False, + ) + identity = tree.identity + transition = await tree.remove_message_subtree(target.reference_id) + lookup_ids = set(transition.removed_message_ids) + if transition.removed_entire_tree: + self._repository.remove_tree(identity) + else: + self._repository.unregister_references(identity, lookup_ids) + cancelled_task = ( + self._processor.cancel(transition.cancellation.active_claim, reason) + if transition.cancellation.active_claim is not None + else None + ) + + if transition.cancellation.queue_update is not None: + await self._processor.notify_queue_updated( + transition.cancellation.queue_update + ) + if cancelled_task is not None: + await _drain_cancelled_tasks([cancelled_task.task]) + snapshot = ( + None + if transition.removed_entire_tree + else await self._current_snapshot(identity, tree) + ) + cancellation = CancellationResult( + effects=self._external_effects( + transition.cancellation, + cancelled_task, + ), + snapshots=(snapshot,) if snapshot is not None else (), + ) + return MessageSubtreeRemovalResult( + cancellation=cancellation, + removed_tree_identity=( + identity if transition.removed_entire_tree else None + ), + delete_message_ids=transition.removed_message_ids, + tree_matched=True, + ) + + def get_tree_count(self) -> int: + return self._repository.tree_count() + + def task_count(self) -> int: + return self._processor.task_count() + + async def wait_idle(self) -> None: + """Wait until every processor-owned claim has finished cleanup.""" + await self._processor.wait_idle() + + async def get_message_ids_for_chat(self, platform: str, chat_id: str) -> set[str]: + async with self._lock: + message_ids: set[str] = set() + for tree in self._repository.trees(): + message_ids.update(await tree.message_ids_for_chat(platform, chat_id)) + return message_ids + + async def snapshot(self) -> ConversationSnapshot: + async with self._lock: + trees: dict[TreeIdentity, TreeSnapshot] = {} + for tree in self._repository.trees(): + trees[tree.identity] = await tree.snapshot() + return ConversationSnapshot(trees=trees) + + @classmethod + def from_snapshot( + cls, + snapshot: ConversationSnapshot, + node_processor: NodeProcessor, + *, + queue_update_callback: QueueUpdateCallback | None = None, + node_started_callback: NodeStartedCallback | None = None, + unexpected_failure_callback: Callable[[FailureResult], None] | None = None, + log_messaging_error_details: bool = False, + ) -> TreeQueueManager: + repository, normalized, stale_targets = TreeRepository.from_snapshot(snapshot) + return cls( + node_processor, + queue_update_callback=queue_update_callback, + node_started_callback=node_started_callback, + unexpected_failure_callback=unexpected_failure_callback, + log_messaging_error_details=log_messaging_error_details, + _repository=repository, + _restored_snapshot=normalized, + _restored_stale_targets=stale_targets, + ) + + +__all__ = ["TreeQueueManager"] diff --git a/src/free_claude_code/messaging/trees/node.py b/src/free_claude_code/messaging/trees/node.py new file mode 100644 index 0000000..9596883 --- /dev/null +++ b/src/free_claude_code/messaging/trees/node.py @@ -0,0 +1,56 @@ +"""Message tree node model.""" + +from dataclasses import dataclass, field +from enum import Enum + +from ..models import MessageScope + + +class MessageState(Enum): + """State of a message node in the tree.""" + + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + ERROR = "error" + + +class MessageReferenceKind(Enum): + """Kind of platform message represented by a tree reference.""" + + PROMPT = "prompt" + STATUS = "status" + + +@dataclass +class MessageNode: + """A single user prompt/status node in a messaging conversation tree.""" + + node_id: str + scope: MessageScope + prompt: str + status_message_id: str | None + state: MessageState = MessageState.PENDING + parent_id: str | None = None + parent_reference_id: str | None = None + session_id: str | None = None + children_ids: list[str] = field(default_factory=list) + + def update_state( + self, + state: MessageState, + *, + session_id: str | None = None, + ) -> None: + self.state = state + if session_id: + self.session_id = session_id + + def mark_error(self) -> None: + self.update_state(MessageState.ERROR) + + def clear_status(self) -> None: + """Invalidate the response and resume point while retaining its prompt.""" + self.status_message_id = None + self.session_id = None + self.mark_error() diff --git a/src/free_claude_code/messaging/trees/processor.py b/src/free_claude_code/messaging/trees/processor.py new file mode 100644 index 0000000..d13eec2 --- /dev/null +++ b/src/free_claude_code/messaging/trees/processor.py @@ -0,0 +1,268 @@ +"""Task execution for claims returned by messaging tree aggregates.""" + +import asyncio +import contextlib +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from loguru import logger + +from ..safe_diagnostics import format_exception_for_log +from .runtime import MessageTree +from .transitions import CancellationReason, NodeClaim, QueueEntry + +NodeProcessor = Callable[[NodeClaim], Awaitable[None]] +QueueUpdateCallback = Callable[[tuple[QueueEntry, ...]], Awaitable[None]] +NodeStartedCallback = Callable[[NodeClaim], Awaitable[None]] +ClaimFailureCallback = Callable[[NodeClaim], Awaitable[None]] +ClaimFinishedCallback = Callable[[MessageTree, NodeClaim], Awaitable[None]] + + +@dataclass(slots=True) +class _TaskSlot: + tree: MessageTree + claim: NodeClaim + task: asyncio.Task[None] | None = None + runner_started: bool = False + transitioned: bool = False + recovery_task: asyncio.Task[None] | None = None + cancellation_requested: bool = False + cancellation_reason: CancellationReason | None = None + + +@dataclass(frozen=True, slots=True) +class CancelledTask: + """Task handle plus whether the node runner owns cancellation UI.""" + + task: asyncio.Task[None] + runner_started: bool + + +class TreeQueueProcessor: + """Own asyncio tasks while MessageTree owns scheduling state.""" + + def __init__( + self, + node_processor: NodeProcessor, + *, + claim_failure_callback: ClaimFailureCallback, + claim_finished_callback: ClaimFinishedCallback, + queue_update_callback: QueueUpdateCallback | None = None, + node_started_callback: NodeStartedCallback | None = None, + log_messaging_error_details: bool = False, + ) -> None: + self._node_processor = node_processor + self._claim_failure_callback = claim_failure_callback + self._claim_finished_callback = claim_finished_callback + self._queue_update_callback = queue_update_callback + self._node_started_callback = node_started_callback + self._log_messaging_error_details = log_messaging_error_details + self._tasks: dict[str, _TaskSlot] = {} + self._completion_failures: list[Exception] = [] + self._idle = asyncio.Event() + self._idle.set() + + @staticmethod + def _key(claim: NodeClaim) -> str: + return claim.claim_id + + def launch( + self, + tree: MessageTree, + claim: NodeClaim, + *, + announce_started: bool = False, + queue: tuple[QueueEntry, ...] = (), + ) -> None: + """Attach a task synchronously before another coroutine can cancel it.""" + key = self._key(claim) + if key in self._tasks: + raise RuntimeError(f"Claim {key} already has a task") + slot = _TaskSlot(tree=tree, claim=claim) + self._tasks[key] = slot + self._idle.clear() + claim_runner = self._run_claim( + slot, + announce_started=announce_started, + queue=queue, + ) + try: + task = asyncio.create_task( + claim_runner, + name=(f"messaging-claim-{claim.identity.root_id}-{claim.claim_id[:8]}"), + eager_start=False, + ) + except BaseException: + claim_runner.close() + if self._tasks.get(key) is slot: + self._tasks.pop(key) + if not self._tasks: + self._idle.set() + raise + slot.task = task + task.add_done_callback(lambda _task, claim_key=key: self._task_done(claim_key)) + + def _task_done(self, key: str) -> None: + """Recover a claim if its task was cancelled before entering its body.""" + slot = self._tasks.get(key) + if slot is None or slot.transitioned or slot.recovery_task is not None: + return + slot.recovery_task = asyncio.create_task( + self._recover_unentered_task(slot), + name=f"messaging-claim-recovery-{key[:8]}", + ) + + async def _recover_unentered_task(self, slot: _TaskSlot) -> None: + task = slot.task + if task is not None: + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + if not slot.transitioned: + await self._finish_and_continue(slot) + + async def _notify_queue_updated(self, queue: tuple[QueueEntry, ...]) -> None: + if self._queue_update_callback is None: + return + try: + await self._queue_update_callback(queue) + except Exception as exc: + logger.warning( + "Queue update callback failed: {}", + format_exception_for_log( + exc, + log_full_message=self._log_messaging_error_details, + ), + ) + + async def notify_queue_updated(self, queue: tuple[QueueEntry, ...]) -> None: + """Publish a transition-owned queue snapshot.""" + await self._notify_queue_updated(queue) + + async def _notify_node_started(self, claim: NodeClaim) -> None: + if self._node_started_callback is None: + return + try: + await self._node_started_callback(claim) + except Exception as exc: + logger.warning( + "Node started callback failed: {}", + format_exception_for_log( + exc, + log_full_message=self._log_messaging_error_details, + ), + ) + + async def _run_claim( + self, + slot: _TaskSlot, + *, + announce_started: bool, + queue: tuple[QueueEntry, ...], + ) -> None: + claim = slot.claim + try: + if announce_started: + await self._notify_node_started(claim) + await self._notify_queue_updated(queue) + if slot.cancellation_requested: + if slot.cancellation_reason is None: + raise asyncio.CancelledError + raise asyncio.CancelledError(slot.cancellation_reason) + slot.runner_started = True + await self._node_processor(claim) + except asyncio.CancelledError: + logger.info("Task for node {} was cancelled", claim.node.node_id) + raise + except Exception as exc: + logger.error( + "Error processing node {}: {}", + claim.node.node_id, + format_exception_for_log( + exc, + log_full_message=self._log_messaging_error_details, + ), + ) + await self._claim_failure_callback(claim) + finally: + if not slot.transitioned: + await self._finish_and_continue(slot) + + async def _finish_and_continue(self, slot: _TaskSlot) -> None: + current = asyncio.current_task() + if current is not None: + while current.cancelling(): + current.uncancel() + try: + while True: + try: + await self._claim_finished_callback(slot.tree, slot.claim) + slot.transitioned = True + break + except asyncio.CancelledError: + if current is not None: + while current.cancelling(): + current.uncancel() + continue + except Exception as exc: + self._completion_failures.append(exc) + logger.error( + "Claim completion callback failed for node {}: {}", + slot.claim.node.node_id, + format_exception_for_log( + exc, + log_full_message=self._log_messaging_error_details, + ), + ) + finally: + key = self._key(slot.claim) + if self._tasks.get(key) is slot: + self._tasks.pop(key) + if not self._tasks: + self._idle.set() + + def cancel( + self, + claim: NodeClaim, + reason: CancellationReason | None, + ) -> CancelledTask | None: + """Cancel exactly the task bound to one aggregate claim.""" + slot = self._tasks.get(self._key(claim)) + if slot is None: + return None + slot.cancellation_requested = True + slot.cancellation_reason = reason + task = slot.task + if task is None or task.done(): + return None + if reason is None: + task.cancel() + else: + task.cancel(reason) + + if slot.runner_started: + return CancelledTask(task=task, runner_started=True) + + if slot.recovery_task is None: + slot.recovery_task = asyncio.create_task( + self._recover_unentered_task(slot), + name=f"messaging-claim-recovery-{claim.claim_id[:8]}", + ) + return CancelledTask(task=slot.recovery_task, runner_started=False) + + def task_count(self) -> int: + """Return the number of attached claims for observability.""" + return len(self._tasks) + + async def wait_idle(self) -> None: + """Wait for every task and hand completion failures to the caller once.""" + await self._idle.wait() + if not self._completion_failures: + return + failures = self._completion_failures + self._completion_failures = [] + if len(failures) == 1: + raise failures[0] + raise ExceptionGroup("Messaging claim completion failures", failures) + + +__all__ = ["CancelledTask", "TreeQueueProcessor"] diff --git a/src/free_claude_code/messaging/trees/queue.py b/src/free_claude_code/messaging/trees/queue.py new file mode 100644 index 0000000..c7bafdc --- /dev/null +++ b/src/free_claude_code/messaging/trees/queue.py @@ -0,0 +1,47 @@ +"""FIFO queue state for one messaging conversation tree.""" + +from collections import deque + + +class MessageNodeQueue: + """Queue with snapshot/remove helpers, backed by a deque and a set index.""" + + def __init__(self, items: list[str] | None = None) -> None: + self._deque: deque[str] = deque() + self._set: set[str] = set() + for item in items or []: + self.put(item) + + def put(self, item: str) -> bool: + """Append a unique item and report whether the queue changed.""" + if item in self._set: + return False + self._deque.append(item) + self._set.add(item) + return True + + def pop(self) -> str | None: + if not self._deque: + return None + item = self._deque.popleft() + self._set.discard(item) + return item + + def qsize(self) -> int: + return len(self._deque) + + def items(self) -> tuple[str, ...]: + return tuple(self._deque) + + def remove(self, item: str) -> bool: + if item not in self._set: + return False + self._set.discard(item) + self._deque = deque(x for x in self._deque if x != item) + return True + + def drain(self) -> tuple[str, ...]: + items = tuple(self._deque) + self._deque.clear() + self._set.clear() + return items diff --git a/src/free_claude_code/messaging/trees/repository.py b/src/free_claude_code/messaging/trees/repository.py new file mode 100644 index 0000000..c7e60ec --- /dev/null +++ b/src/free_claude_code/messaging/trees/repository.py @@ -0,0 +1,135 @@ +"""Manager-owned index of messaging tree aggregates.""" + +from loguru import logger + +from ..models import MessageScope +from .identity import TreeIdentity +from .runtime import MessageTree +from .snapshot import ConversationSnapshot +from .transitions import NodeUiTarget + + +class TreeRepository: + """Store aggregates and map node/status references to their root.""" + + def __init__(self) -> None: + self._trees: dict[TreeIdentity, MessageTree] = {} + self._reference_to_tree: dict[tuple[MessageScope, str], TreeIdentity] = {} + + def get_tree(self, identity: TreeIdentity) -> MessageTree | None: + return self._trees.get(identity) + + def get_tree_for_reference( + self, + scope: MessageScope, + reference_id: str, + ) -> MessageTree | None: + identity = self._reference_to_tree.get((scope, reference_id)) + return self._trees.get(identity) if identity is not None else None + + def has_reference(self, scope: MessageScope, reference_id: str) -> bool: + return (scope, reference_id) in self._reference_to_tree + + def add_tree( + self, + tree: MessageTree, + *, + node_id: str, + status_message_id: str, + ) -> None: + identity = tree.identity + if identity in self._trees: + raise ValueError(f"Tree {identity} already exists") + self.register_node( + identity=identity, + node_id=node_id, + status_message_id=status_message_id, + ) + self._trees[identity] = tree + logger.debug("TREE_REPO: add_tree identity={}", identity) + + def register_node( + self, + *, + identity: TreeIdentity, + node_id: str, + status_message_id: str, + ) -> None: + if self.has_reference(identity.scope, node_id) or self.has_reference( + identity.scope, status_message_id + ): + raise ValueError("Node or status message is already registered") + self._reference_to_tree[(identity.scope, node_id)] = identity + self._reference_to_tree[(identity.scope, status_message_id)] = identity + + def unregister_references( + self, + identity: TreeIdentity, + reference_ids: set[str], + ) -> None: + for reference_id in reference_ids: + key = (identity.scope, reference_id) + if self._reference_to_tree.get(key) == identity: + self._reference_to_tree.pop(key) + + def remove_tree( + self, + identity: TreeIdentity, + ) -> MessageTree | None: + tree = self._trees.pop(identity, None) + if tree is None: + return None + self._reference_to_tree = { + key: owner + for key, owner in self._reference_to_tree.items() + if owner != identity + } + logger.debug("TREE_REPO: remove_tree identity={}", identity) + return tree + + def trees(self) -> tuple[MessageTree, ...]: + return tuple(self._trees.values()) + + def tree_count(self) -> int: + return len(self._trees) + + @classmethod + def from_snapshot( + cls, snapshot: ConversationSnapshot + ) -> tuple[ + TreeRepository, + ConversationSnapshot, + tuple[NodeUiTarget, ...], + ]: + repo = cls() + normalized = ConversationSnapshot() + stale_targets: list[NodeUiTarget] = [] + for tree_snapshot in snapshot.trees.values(): + try: + tree = MessageTree.from_snapshot(tree_snapshot) + except (KeyError, TypeError, ValueError) as exc: + logger.warning( + "Skipping invalid messaging tree snapshot: {}", + type(exc).__name__, + ) + continue + references = tree_snapshot.lookup_ids() + if tree.identity in repo._trees or any( + repo.has_reference(tree.identity.scope, reference) + for reference in references + ): + logger.warning("Skipping duplicate messaging tree {}", tree.identity) + continue + repo._trees[tree.identity] = tree + for reference in references: + repo._reference_to_tree[(tree.identity.scope, reference)] = ( + tree.identity + ) + stale_targets.extend(tree.restored_stale_targets) + normalized_tree = tree.restored_snapshot + if normalized_tree is not None: + normalized = normalized.with_tree(normalized_tree) + return repo, normalized, tuple(stale_targets) + + +__all__ = ["TreeRepository"] diff --git a/src/free_claude_code/messaging/trees/runtime.py b/src/free_claude_code/messaging/trees/runtime.py new file mode 100644 index 0000000..c3d2b32 --- /dev/null +++ b/src/free_claude_code/messaging/trees/runtime.py @@ -0,0 +1,487 @@ +"""Atomic runtime aggregate for one messaging conversation tree.""" + +import asyncio +from dataclasses import dataclass +from uuid import uuid4 + +from loguru import logger + +from ..models import MessageScope +from .graph import MessageTreeGraph +from .identity import TreeIdentity +from .node import MessageNode, MessageReferenceKind, MessageState +from .queue import MessageNodeQueue +from .snapshot import TreeSnapshot +from .transitions import ( + AdmissionRejection, + CompletionResult, + FailureResult, + MessageSubtreeRemoval, + NodeClaim, + NodeUiTarget, + NodeView, + QueueDecision, + QueueEntry, + ReplyTarget, + TreeCancellation, +) + + +@dataclass(slots=True) +class _ActiveClaim: + """Runtime execution identity kept separate from the node's UI state.""" + + claim: NodeClaim + cancellation_requested: bool = False + + +class MessageTree: + """Own graph, queue, claim identity, and every concurrency invariant.""" + + def __init__( + self, + root_node: MessageNode, + *, + graph: MessageTreeGraph | None = None, + ) -> None: + self._graph = graph or MessageTreeGraph(root_node) + self._queue = MessageNodeQueue() + self._lock = asyncio.Lock() + self._active: _ActiveClaim | None = None + self._restored_snapshot: TreeSnapshot | None = None + self._restored_stale_targets: tuple[NodeUiTarget, ...] = () + logger.debug("Created MessageTree with root {}", self.root_id) + + @property + def root_id(self) -> str: + return self._graph.root_id + + @property + def identity(self) -> TreeIdentity: + return self._graph.identity + + @property + def restored_snapshot(self) -> TreeSnapshot | None: + """Normalized startup snapshot captured before the tree is published.""" + return self._restored_snapshot + + @property + def restored_stale_targets(self) -> tuple[NodeUiTarget, ...]: + """UI targets normalized from runnable to interrupted on restore.""" + return self._restored_stale_targets + + def _ui_target(self, node: MessageNode) -> NodeUiTarget: + if node.status_message_id is None: + raise ValueError("Runnable node has no status message") + return NodeUiTarget( + scope=node.scope, + node_id=node.node_id, + status_message_id=node.status_message_id, + ) + + def _queue_entries(self) -> tuple[QueueEntry, ...]: + entries: list[QueueEntry] = [] + for node_id in self._queue.items(): + node = self._graph.get_node(node_id) + if node is None or node.state is not MessageState.PENDING: + continue + entries.append( + QueueEntry(node=self._ui_target(node), position=len(entries) + 1) + ) + return tuple(entries) + + def _claim(self, node: MessageNode) -> NodeClaim: + node.update_state(MessageState.IN_PROGRESS) + claim = NodeClaim( + identity=self.identity, + claim_id=uuid4().hex, + node=self._ui_target(node), + prompt=node.prompt, + parent_session_id=self._graph.get_parent_session_id(node.node_id), + ) + self._active = _ActiveClaim(claim=claim) + return claim + + def _enqueue_or_claim(self, node_id: str) -> QueueDecision: + node = self._graph.get_node(node_id) + if node is None or node.state is not MessageState.PENDING: + return QueueDecision( + claim=None, + position=None, + snapshot=None, + rejection=AdmissionRejection.DUPLICATE, + ) + + if self._active is None: + claim = self._claim(node) + return QueueDecision( + claim=claim, + position=None, + snapshot=self._graph.snapshot(), + ) + + if not self._queue.put(node_id): + return QueueDecision( + claim=None, + position=None, + snapshot=None, + rejection=AdmissionRejection.DUPLICATE, + ) + + position = self._queue.qsize() + logger.info("Queued node {}, position {}", node_id, position) + return QueueDecision( + claim=None, + position=position, + snapshot=self._graph.snapshot(), + ) + + async def enqueue_or_claim(self, node_id: str) -> QueueDecision: + """Atomically reject, queue, or exclusively claim an existing node.""" + async with self._lock: + return self._enqueue_or_claim(node_id) + + async def add_and_enqueue( + self, + node_id: str, + scope: MessageScope, + prompt: str, + status_message_id: str, + parent_id: str, + parent_reference_id: str, + ) -> QueueDecision: + """Atomically add a reply and admit it to this tree.""" + async with self._lock: + self._graph.add_node( + node_id=node_id, + scope=scope, + prompt=prompt, + status_message_id=status_message_id, + parent_id=parent_id, + parent_reference_id=parent_reference_id, + ) + return self._enqueue_or_claim(node_id) + + async def finish_and_claim_next(self, claim_id: str) -> CompletionResult: + """Release only the matching claim and atomically select its successor.""" + async with self._lock: + if self._active is None or self._active.claim.claim_id != claim_id: + return CompletionResult( + next_claim=None, + queue=self._queue_entries(), + ) + + self._active = None + next_claim: NodeClaim | None = None + while node_id := self._queue.pop(): + node = self._graph.get_node(node_id) + if node is not None and node.state is MessageState.PENDING: + next_claim = self._claim(node) + break + + return CompletionResult( + next_claim=next_claim, + queue=self._queue_entries(), + ) + + async def cancel_node( + self, + node_id: str, + ) -> TreeCancellation: + """Atomically cancel one active, queued, or stale runnable node.""" + async with self._lock: + node = self._graph.get_node(node_id) + active_claim = ( + self._active.claim + if self._active is not None + and self._active.claim.node.node_id == node_id + else None + ) + if active_claim is not None: + active = self._active + if active is not None: + active.cancellation_requested = True + if node is None: + return TreeCancellation( + nodes=(), + active_claim=active_claim, + queue_update=None, + ) + + queue_changed = self._queue.remove(node_id) + cancelled_nodes: tuple[NodeUiTarget, ...] = () + if node.state in (MessageState.PENDING, MessageState.IN_PROGRESS): + node.mark_error() + cancelled_nodes = (self._ui_target(node),) + elif node.state is MessageState.ERROR and active_claim is not None: + cancelled_nodes = (self._ui_target(node),) + return TreeCancellation( + nodes=cancelled_nodes, + active_claim=active_claim, + queue_update=self._queue_entries() if queue_changed else None, + ) + + async def cancel_all( + self, + ) -> TreeCancellation: + """Atomically cancel every runnable node present at the transition.""" + async with self._lock: + cancelled_nodes: list[NodeUiTarget] = [] + seen: set[str] = set() + active_claim: NodeClaim | None = None + + if self._active is not None: + active_claim = self._active.claim + self._active.cancellation_requested = True + active_node = self._graph.get_node(active_claim.node.node_id) + if active_node is not None and active_node.state in ( + MessageState.PENDING, + MessageState.IN_PROGRESS, + ): + active_node.mark_error() + seen.add(active_node.node_id) + cancelled_nodes.append(self._ui_target(active_node)) + elif active_node is not None: + seen.add(active_node.node_id) + if active_node.state is MessageState.ERROR: + cancelled_nodes.append(self._ui_target(active_node)) + + queued_ids = self._queue.drain() + for node_id in queued_ids: + node = self._graph.get_node(node_id) + if node is None or node.state not in ( + MessageState.PENDING, + MessageState.IN_PROGRESS, + ): + continue + node.mark_error() + seen.add(node_id) + cancelled_nodes.append(self._ui_target(node)) + + for node in self._graph.all_nodes(): + if node.node_id in seen or node.state not in ( + MessageState.PENDING, + MessageState.IN_PROGRESS, + ): + continue + node.mark_error() + cancelled_nodes.append(self._ui_target(node)) + + return TreeCancellation( + nodes=tuple(cancelled_nodes), + active_claim=active_claim, + queue_update=() if queued_ids else None, + ) + + async def remove_message_subtree( + self, + reference_id: str, + ) -> MessageSubtreeRemoval: + """Atomically cancel and detach one literal platform reply subtree.""" + async with self._lock: + resolved = self._graph.resolve_reference(reference_id) + reference_ids = tuple(self._graph.get_reference_descendants(reference_id)) + if resolved is None or not reference_ids: + empty = TreeCancellation( + nodes=(), + active_claim=None, + queue_update=None, + ) + return MessageSubtreeRemoval( + cancellation=empty, + removed_message_ids=frozenset(), + removed_entire_tree=False, + ) + + owner, reference_kind = resolved + removed_node_ids = { + candidate + for candidate in reference_ids + if self._graph.get_node(candidate) is not None + } + affected_node_ids = set(removed_node_ids) + if reference_kind is MessageReferenceKind.STATUS: + affected_node_ids.add(owner.node_id) + active_claim = ( + self._active.claim + if self._active is not None + and self._active.claim.node.node_id in affected_node_ids + else None + ) + if active_claim is not None: + active = self._active + if active is not None: + active.cancellation_requested = True + cancelled_nodes: list[NodeUiTarget] = [] + queue_changed = False + + for node_id in affected_node_ids: + node = self._graph.get_node(node_id) + if node is None: + continue + queue_changed = self._queue.remove(node_id) or queue_changed + if node.state in (MessageState.PENDING, MessageState.IN_PROGRESS): + target = self._ui_target(node) + node.mark_error() + cancelled_nodes.append(target) + elif ( + node.state is MessageState.ERROR + and active_claim is not None + and active_claim.node.node_id == node_id + ): + cancelled_nodes.append(self._ui_target(node)) + + if reference_kind is MessageReferenceKind.STATUS: + self._graph.clear_status(owner.node_id) + removed_entire_tree = self.root_id in removed_node_ids + self._graph.remove_nodes(removed_node_ids) + cancellation = TreeCancellation( + nodes=tuple(cancelled_nodes), + active_claim=active_claim, + queue_update=self._queue_entries() if queue_changed else None, + ) + return MessageSubtreeRemoval( + cancellation=cancellation, + removed_message_ids=frozenset(reference_ids), + removed_entire_tree=removed_entire_tree, + ) + + async def record_session( + self, claim_id: str, session_id: str + ) -> TreeSnapshot | None: + """Record a real CLI session only for the currently active claim.""" + async with self._lock: + if ( + self._active is None + or self._active.claim.claim_id != claim_id + or self._active.cancellation_requested + ): + return None + node = self._graph.get_node(self._active.claim.node.node_id) + if node is None or node.state is not MessageState.IN_PROGRESS: + return None + node.update_state(MessageState.IN_PROGRESS, session_id=session_id) + return self._graph.snapshot() + + async def complete_claim( + self, claim_id: str, session_id: str | None + ) -> TreeSnapshot | None: + """Mark the currently active claim complete.""" + async with self._lock: + if ( + self._active is None + or self._active.claim.claim_id != claim_id + or self._active.cancellation_requested + ): + return None + node = self._graph.get_node(self._active.claim.node.node_id) + if node is None or node.state not in ( + MessageState.IN_PROGRESS, + MessageState.ERROR, + ): + return None + node.update_state(MessageState.COMPLETED, session_id=session_id) + return self._graph.snapshot() + + async def fail_claim( + self, + claim_id: str, + *, + propagate: bool, + ) -> FailureResult: + """Atomically fail the active claim and its pending descendants.""" + async with self._lock: + if ( + self._active is None + or self._active.claim.claim_id != claim_id + or self._active.cancellation_requested + ): + return FailureResult(affected=(), queue_update=None, snapshot=None) + node = self._graph.get_node(self._active.claim.node.node_id) + if node is None: + return FailureResult(affected=(), queue_update=None, snapshot=None) + + affected: list[NodeUiTarget] = [] + queue_changed = False + if node.state is not MessageState.COMPLETED: + if node.state is not MessageState.ERROR: + node.mark_error() + affected.append(self._ui_target(node)) + + if propagate: + for descendant_id in self._graph.get_descendants(node.node_id)[1:]: + child = self._graph.get_node(descendant_id) + if child is None or child.state is not MessageState.PENDING: + continue + child.mark_error() + queue_changed = ( + self._queue.remove(child.node_id) or queue_changed + ) + affected.append(self._ui_target(child)) + + return FailureResult( + affected=tuple(affected), + queue_update=self._queue_entries() if queue_changed else None, + snapshot=self._graph.snapshot(), + ) + + async def resolve_reply(self, reference_id: str) -> ReplyTarget | None: + """Resolve a node/status reference without exposing the mutable graph.""" + async with self._lock: + resolved = self._graph.resolve_reference(reference_id) + if resolved is None: + return None + node, reference_kind = resolved + return ReplyTarget( + node_id=node.node_id, + reference_id=reference_id, + reference_kind=reference_kind, + queue_position=(self._queue.qsize() + 1) + if self._active is not None + else None, + ) + + async def node_view(self, node_id: str) -> NodeView | None: + """Return a copied node read model.""" + async with self._lock: + node = self._graph.get_node(node_id) + if node is None: + return None + return NodeView( + identity=self.identity, + node_id=node.node_id, + state=node.state, + parent_id=node.parent_id, + session_id=node.session_id, + ) + + async def snapshot(self) -> TreeSnapshot: + """Capture a detached persistence snapshot under the aggregate lock.""" + async with self._lock: + return self._graph.snapshot() + + async def message_ids_for_chat(self, platform: str, chat_id: str) -> set[str]: + """Copy every prompt and FCC status belonging to one platform chat.""" + async with self._lock: + if self.identity.scope.platform != str(platform) or ( + self.identity.scope.chat_id != str(chat_id) + ): + return set() + return self._graph.all_reference_ids() + + @classmethod + def from_snapshot(cls, snapshot: TreeSnapshot) -> MessageTree: + """Restore and reconcile interrupted nodes before publishing the tree.""" + graph = MessageTreeGraph.from_snapshot(snapshot) + tree = cls(graph.get_root(), graph=graph) + stale_targets: list[NodeUiTarget] = [] + for node in graph.all_nodes(): + if node.state in (MessageState.PENDING, MessageState.IN_PROGRESS): + stale_targets.append(tree._ui_target(node)) + node.mark_error() + tree._restored_stale_targets = tuple(stale_targets) + tree._restored_snapshot = graph.snapshot() + return tree + + +__all__ = ["MessageTree"] diff --git a/src/free_claude_code/messaging/trees/snapshot.py b/src/free_claude_code/messaging/trees/snapshot.py new file mode 100644 index 0000000..83ec27a --- /dev/null +++ b/src/free_claude_code/messaging/trees/snapshot.py @@ -0,0 +1,221 @@ +"""Serializable messaging conversation snapshots.""" + +from dataclasses import dataclass, field +from typing import Any + +from loguru import logger + +from ..models import MessageScope +from .identity import TreeIdentity +from .node import MessageNode, MessageState + + +@dataclass(frozen=True) +class TreeSnapshot: + """Detached persisted representation of one conversation tree.""" + + scope: MessageScope + root_id: str + nodes: dict[str, dict[str, Any]] + + @property + def identity(self) -> TreeIdentity: + return TreeIdentity(scope=self.scope, root_id=self.root_id) + + def to_json(self) -> dict[str, Any]: + return { + "scope": { + "platform": self.scope.platform, + "chat_id": self.scope.chat_id, + }, + "root_id": self.root_id, + "nodes": dict(self.nodes), + } + + @classmethod + def from_json(cls, data: Any) -> TreeSnapshot | None: + if not isinstance(data, dict): + return None + root_id = data.get("root_id") + nodes = data.get("nodes") + if root_id is None or not isinstance(nodes, dict): + return None + normalized_root_id = str(root_id) + scope_data = data.get("scope") + scope: MessageScope | None = None + if isinstance(scope_data, dict): + platform = scope_data.get("platform") + chat_id = scope_data.get("chat_id") + if platform is not None and chat_id is not None: + scope = MessageScope(platform=str(platform), chat_id=str(chat_id)) + if scope is None: + scope = _legacy_scope(normalized_root_id, nodes) + if scope is None: + logger.warning( + "Skipping messaging tree snapshot without recoverable scope: " + "root_id={}", + normalized_root_id, + ) + return None + return cls(scope=scope, root_id=normalized_root_id, nodes=dict(nodes)) + + def lookup_ids(self) -> set[str]: + lookup_ids: set[str] = set() + for node_key, node_data in self.nodes.items(): + lookup_ids.add(str(node_key)) + if not isinstance(node_data, dict): + continue + node_id = node_data.get("node_id") + if node_id is not None: + lookup_ids.add(str(node_id)) + status_message_id = node_data.get("status_message_id") + if status_message_id is not None: + lookup_ids.add(str(status_message_id)) + return lookup_ids + + +@dataclass(frozen=True) +class ConversationSnapshot: + """Detached persisted conversation trees keyed by customer identity.""" + + trees: dict[TreeIdentity, TreeSnapshot] = field(default_factory=dict) + + @property + def is_empty(self) -> bool: + return not self.trees + + def to_json(self) -> dict[str, Any]: + return {"trees": [tree.to_json() for tree in self.trees.values()]} + + @classmethod + def from_json(cls, data: Any) -> ConversationSnapshot: + if not isinstance(data, dict): + return cls() + raw_trees = data.get("trees", []) + if isinstance(raw_trees, dict): + candidates = raw_trees.values() + elif isinstance(raw_trees, list): + candidates = raw_trees + else: + return cls() + + trees: dict[TreeIdentity, TreeSnapshot] = {} + for raw_tree in candidates: + snapshot = TreeSnapshot.from_json(raw_tree) + if snapshot is None: + continue + trees[snapshot.identity] = snapshot + return cls(trees=trees) + + def get_tree(self, identity: TreeIdentity) -> TreeSnapshot | None: + return self.trees.get(identity) + + def with_tree(self, tree_snapshot: TreeSnapshot) -> ConversationSnapshot: + trees = dict(self.trees) + trees[tree_snapshot.identity] = tree_snapshot + return ConversationSnapshot(trees=trees) + + def without_tree(self, identity: TreeIdentity) -> ConversationSnapshot: + trees = dict(self.trees) + trees.pop(identity, None) + return ConversationSnapshot(trees=trees) + + def without_scope(self, scope: MessageScope) -> ConversationSnapshot: + """Remove every tree belonging to one platform chat.""" + return ConversationSnapshot( + trees={ + identity: tree + for identity, tree in self.trees.items() + if identity.scope != scope + } + ) + + +def _legacy_scope( + root_id: str, + nodes: dict[str, Any], +) -> MessageScope | None: + """Derive scope from pre-scope snapshots without retaining a runtime shim.""" + root = nodes.get(root_id) + if not isinstance(root, dict): + root = next( + ( + node + for node in nodes.values() + if isinstance(node, dict) and str(node.get("node_id")) == root_id + ), + None, + ) + if not isinstance(root, dict): + return None + return node_scope_from_snapshot(root) + + +def node_scope_from_snapshot(data: dict[str, Any]) -> MessageScope | None: + """Read the redundant scope carried by legacy node payloads, if present.""" + incoming = data.get("incoming") + if not isinstance(incoming, dict): + return None + platform = incoming.get("platform") + chat_id = incoming.get("chat_id") + if platform is None or chat_id is None: + return None + return MessageScope(platform=str(platform), chat_id=str(chat_id)) + + +def node_to_snapshot(node: MessageNode) -> dict[str, Any]: + return { + "node_id": node.node_id, + "status_message_id": node.status_message_id, + "state": node.state.value, + "parent_id": node.parent_id, + "parent_reference_id": node.parent_reference_id, + "session_id": node.session_id, + } + + +def node_from_snapshot(data: dict[str, Any], scope: MessageScope) -> MessageNode: + state = MessageState(data["state"]) + status_message_id = _optional_id( + data.get("status_message_id"), + "status_message_id", + ) + if state in (MessageState.PENDING, MessageState.IN_PROGRESS) and ( + status_message_id is None + ): + raise ValueError("Runnable tree snapshot node requires a status message") + parent_id = _optional_id(data.get("parent_id"), "parent_id") + parent_reference_id = _optional_id( + data.get("parent_reference_id"), + "parent_reference_id", + ) + if parent_reference_id is None: + parent_reference_id = parent_id + return MessageNode( + node_id=_required_id(data.get("node_id"), "node_id"), + scope=scope, + prompt="", + status_message_id=status_message_id, + state=state, + parent_id=parent_id, + parent_reference_id=parent_reference_id, + session_id=_optional_id(data.get("session_id"), "session_id"), + ) + + +def _required_id(value: Any, field_name: str) -> str: + normalized = _optional_id(value, field_name) + if normalized is None: + raise ValueError(f"Tree snapshot {field_name} is required") + return normalized + + +def _optional_id(value: Any, field_name: str) -> str | None: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, str | int): + raise ValueError(f"Tree snapshot {field_name} must be a string or integer") + normalized = str(value) + if not normalized: + raise ValueError(f"Tree snapshot {field_name} cannot be empty") + return normalized diff --git a/src/free_claude_code/messaging/trees/transitions.py b/src/free_claude_code/messaging/trees/transitions.py new file mode 100644 index 0000000..38b9901 --- /dev/null +++ b/src/free_claude_code/messaging/trees/transitions.py @@ -0,0 +1,174 @@ +"""Detached transition values crossing the messaging tree ownership boundary.""" + +from dataclasses import dataclass +from enum import Enum + +from ..models import MessageScope +from .identity import TreeIdentity +from .node import MessageReferenceKind, MessageState +from .snapshot import TreeSnapshot + + +class CancellationUiOwner(Enum): + """Component responsible for the final user-visible cancellation edit.""" + + RUNNER = "runner" + WORKFLOW = "workflow" + + +class CancellationReason(Enum): + """Customer operation that cancelled a running messaging claim.""" + + STOP = "stop" + CLEAR = "clear" + + +class AdmissionRejection(Enum): + """Why a turn was not admitted to an execution tree.""" + + DUPLICATE = "duplicate" + PARENT_REMOVED = "parent_removed" + + +@dataclass(frozen=True, slots=True) +class NodeUiTarget: + """Copied node coordinates needed for an external UI effect.""" + + scope: MessageScope + node_id: str + status_message_id: str + + +@dataclass(frozen=True, slots=True) +class NodeClaim: + """Exclusive permission to execute one node in a conversation tree.""" + + identity: TreeIdentity + claim_id: str + node: NodeUiTarget + prompt: str + parent_session_id: str | None + + +@dataclass(frozen=True, slots=True) +class QueueEntry: + """One immutable queue-position update.""" + + node: NodeUiTarget + position: int + + +@dataclass(frozen=True, slots=True) +class QueueDecision: + """Atomic result of admitting a node to a tree.""" + + claim: NodeClaim | None + position: int | None + snapshot: TreeSnapshot | None + rejection: AdmissionRejection | None = None + + @property + def accepted(self) -> bool: + return self.snapshot is not None + + +@dataclass(frozen=True, slots=True) +class CompletionResult: + """Atomic result of releasing a claim and selecting its successor.""" + + next_claim: NodeClaim | None + queue: tuple[QueueEntry, ...] + + +@dataclass(frozen=True, slots=True) +class CancellationEffect: + """Copied cancellation fact for the UI layer.""" + + node: NodeUiTarget + ui_owner: CancellationUiOwner + + +@dataclass(frozen=True, slots=True) +class TreeCancellation: + """Single-tree cancellation transition consumed by the manager.""" + + nodes: tuple[NodeUiTarget, ...] + active_claim: NodeClaim | None + queue_update: tuple[QueueEntry, ...] | None + + +@dataclass(frozen=True, slots=True) +class CancellationResult: + """External effects and persistence snapshots from a cancellation request.""" + + effects: tuple[CancellationEffect, ...] = () + snapshots: tuple[TreeSnapshot, ...] = () + + +@dataclass(frozen=True, slots=True) +class MessageSubtreeRemoval: + """Single-tree atomic cancellation and reference-subtree removal.""" + + cancellation: TreeCancellation + removed_message_ids: frozenset[str] + removed_entire_tree: bool + + +@dataclass(frozen=True, slots=True) +class MessageSubtreeRemovalResult: + """Manager result for a literal message-subtree clear.""" + + cancellation: CancellationResult + removed_tree_identity: TreeIdentity | None + delete_message_ids: frozenset[str] + tree_matched: bool + + +@dataclass(frozen=True, slots=True) +class ReplyTarget: + """Resolved reply destination and advisory position before admission.""" + + node_id: str + reference_id: str + reference_kind: MessageReferenceKind + queue_position: int | None + + +@dataclass(frozen=True, slots=True) +class NodeView: + """Immutable read model for diagnostics, tests, and smoke assertions.""" + + identity: TreeIdentity + node_id: str + state: MessageState + parent_id: str | None + session_id: str | None + + +@dataclass(frozen=True, slots=True) +class FailureResult: + """Atomic node failure plus copied child UI effects.""" + + affected: tuple[NodeUiTarget, ...] + queue_update: tuple[QueueEntry, ...] | None + snapshot: TreeSnapshot | None + + +__all__ = [ + "AdmissionRejection", + "CancellationEffect", + "CancellationReason", + "CancellationResult", + "CancellationUiOwner", + "CompletionResult", + "FailureResult", + "MessageSubtreeRemoval", + "MessageSubtreeRemovalResult", + "NodeClaim", + "NodeUiTarget", + "NodeView", + "QueueDecision", + "QueueEntry", + "ReplyTarget", + "TreeCancellation", +] diff --git a/src/free_claude_code/messaging/turn_intake.py b/src/free_claude_code/messaging/turn_intake.py new file mode 100644 index 0000000..a16b2a2 --- /dev/null +++ b/src/free_claude_code/messaging/turn_intake.py @@ -0,0 +1,241 @@ +"""Inbound messaging turn intake and queue admission.""" + +from collections.abc import Awaitable, Callable + +from loguru import logger + +from free_claude_code.core.trace import trace_event + +from .cli_event_constants import STATUS_MESSAGE_PREFIXES +from .command_context import MessagingCommandContext +from .command_dispatcher import ( + dispatch_command, + parse_command_base, +) +from .models import AdmissionToken, IncomingMessage, MessageScope +from .platforms.ports import OutboundMessenger +from .session import SessionStore +from .trees import ( + AdmissionRejection, + NodeClaim, + QueueDecision, + QueueEntry, + ReplyTarget, +) + + +class MessagingTurnIntake: + """Owns inbound turn classification and queue admission.""" + + def __init__( + self, + *, + platform_name: str, + outbound: OutboundMessenger, + session_store: SessionStore, + command_context: MessagingCommandContext, + resolve_reply: Callable[[MessageScope, str], Awaitable[ReplyTarget | None]], + admit_turn: Callable[ + [IncomingMessage, str, str | None, AdmissionToken], + Awaitable[QueueDecision | None], + ], + format_status: Callable[[str, str, str | None], str], + get_parse_mode: Callable[[], str | None], + record_outgoing_message: Callable[[str, str, str | None, str], bool], + ) -> None: + self.platform_name = platform_name + self.outbound = outbound + self.session_store = session_store + self._command_context = command_context + self._resolve_reply = resolve_reply + self._admit_turn = admit_turn + self._format_status = format_status + self._get_parse_mode = get_parse_mode + self._record_outgoing_message = record_outgoing_message + + async def handle_message( + self, + incoming: IncomingMessage, + *, + admission_token: AdmissionToken, + ) -> None: + """ + Handle an inbound platform message and queue it if it is a user prompt. + """ + cmd_base = parse_command_base(incoming.text) + + if await dispatch_command(self._command_context, incoming, cmd_base): + return + + text = incoming.text or "" + if any(text.startswith(p) for p in STATUS_MESSAGE_PREFIXES): + return + + reply_target: ReplyTarget | None = None + + if incoming.is_reply() and incoming.reply_to_message_id: + reply_id = incoming.reply_to_message_id + reply_target = await self._resolve_reply(incoming.scope, reply_id) + if reply_target is not None: + logger.info( + "Found tree for reply, parent node: {}", reply_target.node_id + ) + + node_id = incoming.message_id + status_text = self._get_initial_status(reply_target) + if incoming.status_message_id: + status_msg_id = incoming.status_message_id + await self.outbound.queue_edit_message( + incoming.chat_id, + status_msg_id, + status_text, + parse_mode=self._get_parse_mode(), + fire_and_forget=False, + ) + else: + status_msg_id = await self.outbound.queue_send_message( + incoming.chat_id, + status_text, + reply_to=incoming.message_id, + fire_and_forget=False, + message_thread_id=incoming.message_thread_id, + ) + self._record_outgoing_message( + incoming.platform, incoming.chat_id, status_msg_id, "status" + ) + if status_msg_id is None: + return + + decision = await self._admit_turn( + incoming, + status_msg_id, + reply_target.reference_id if reply_target is not None else None, + admission_token, + ) + if decision is None: + logger.info( + "Discarded messaging admission invalidated by a stop/clear boundary for node {}", + node_id, + ) + await self._discard_rejected_messages( + incoming, + status_msg_id, + include_prompt=False, + ) + return + if not decision.accepted: + include_prompt = decision.rejection is AdmissionRejection.PARENT_REMOVED + logger.debug( + "Rejected messaging admission for node {}: {}", + node_id, + decision.rejection.value + if decision.rejection is not None + else "unknown", + ) + await self._discard_rejected_messages( + incoming, + status_msg_id, + include_prompt=include_prompt, + ) + return + + if decision.position is not None and status_msg_id: + trace_event( + stage="routing", + event="turn.queued", + source=self.platform_name, + chat_id=incoming.chat_id, + platform_message_id=node_id, + status_message_id=status_msg_id, + queue_size=decision.position, + ) + await self.outbound.queue_edit_message( + incoming.chat_id, + status_msg_id, + self._format_status( + "📋", + "Queued", + f"(position {decision.position}) - waiting...", + ), + parse_mode=self._get_parse_mode(), + ) + + async def _discard_rejected_messages( + self, + incoming: IncomingMessage, + status_message_id: str, + *, + include_prompt: bool, + ) -> None: + """Remove messages created by an admission that cannot commit.""" + message_ids = {status_message_id} + if include_prompt: + message_ids.add(str(incoming.message_id)) + try: + await self.outbound.queue_delete_messages( + incoming.chat_id, + list(message_ids), + fire_and_forget=False, + ) + except Exception as exc: + logger.debug( + "Failed to remove rejected status message: {}", + type(exc).__name__, + ) + try: + self.session_store.forget_tracked_message_ids( + incoming.platform, + incoming.chat_id, + message_ids, + ) + except Exception as exc: + logger.debug( + "Failed to forget rejected status message: {}", + type(exc).__name__, + ) + + async def update_queue_positions(self, queue: tuple[QueueEntry, ...]) -> None: + """Refresh queued status messages after a dequeue.""" + for entry in queue: + self.outbound.fire_and_forget( + self.outbound.queue_edit_message( + entry.node.scope.chat_id, + entry.node.status_message_id, + self._format_status( + "📋", + "Queued", + f"(position {entry.position}) - waiting...", + ), + parse_mode=self._get_parse_mode(), + ) + ) + + async def mark_node_processing(self, claim: NodeClaim) -> None: + """Update the dequeued node's status to processing immediately.""" + self.outbound.fire_and_forget( + self.outbound.queue_edit_message( + claim.node.scope.chat_id, + claim.node.status_message_id, + self._format_status("🔄", "Processing...", None), + parse_mode=self._get_parse_mode(), + ) + ) + + def _get_initial_status( + self, + reply_target: ReplyTarget | None, + ) -> str: + """Get initial status message text.""" + if reply_target is not None: + if reply_target.queue_position is not None: + return self._format_status( + "📋", + "Queued", + f"(position {reply_target.queue_position}) - waiting...", + ) + return self._format_status("🔄", "Continuing conversation...", None) + + return self._format_status("⏳", "Launching new Claude CLI instance...", None) + + +__all__ = ["MessagingTurnIntake"] diff --git a/src/free_claude_code/messaging/ui_updates.py b/src/free_claude_code/messaging/ui_updates.py new file mode 100644 index 0000000..55c0ff4 --- /dev/null +++ b/src/free_claude_code/messaging/ui_updates.py @@ -0,0 +1,99 @@ +"""Throttled platform UI updates driven by transcript rendering.""" + +import time +from collections.abc import Callable + +from loguru import logger + +from .platforms.ports import OutboundMessenger +from .safe_diagnostics import format_exception_for_log +from .transcript import RenderCtx, TranscriptBuffer + + +class ThrottledTranscriptEditor: + """Rate-limited status message edits from a growing transcript.""" + + def __init__( + self, + *, + outbound: OutboundMessenger, + parse_mode: str | None, + get_limit_chars: Callable[[], int], + transcript: TranscriptBuffer, + render_ctx: RenderCtx, + node_id: str, + chat_id: str, + status_msg_id: str, + debug_platform_edits: bool, + log_messaging_error_details: bool = False, + ) -> None: + self._outbound = outbound + self._parse_mode = parse_mode + self._get_limit_chars = get_limit_chars + self._transcript = transcript + self._render_ctx = render_ctx + self._node_id = node_id + self._chat_id = chat_id + self._status_msg_id = status_msg_id + self._debug_platform_edits = debug_platform_edits + self._log_messaging_error_details = log_messaging_error_details + self._last_ui_update = 0.0 + self._last_displayed_text: str | None = None + self._last_status: str | None = None + + @property + def last_status(self) -> str | None: + return self._last_status + + async def update(self, status: str | None = None, *, force: bool = False) -> None: + """Render transcript + optional status line and edit the platform message.""" + now = time.time() + if not force and now - self._last_ui_update < 1.0: + return + + self._last_ui_update = now + if status is not None: + self._last_status = status + try: + display = self._transcript.render( + self._render_ctx, + limit_chars=self._get_limit_chars(), + status=status, + ) + except Exception as e: + logger.warning( + "Transcript render failed for node {}: {}", + self._node_id, + format_exception_for_log( + e, log_full_message=self._log_messaging_error_details + ), + ) + return + if display and display != self._last_displayed_text: + logger.debug( + "PLATFORM_EDIT: node_id={} chat_id={} msg_id={} force={} status={!r} chars={}", + self._node_id, + self._chat_id, + self._status_msg_id, + bool(force), + status, + len(display), + ) + if self._debug_platform_edits: + logger.debug("PLATFORM_EDIT_TEXT:\n{}", display) + self._last_displayed_text = display + try: + await self._outbound.queue_edit_message( + self._chat_id, + self._status_msg_id, + display, + parse_mode=self._parse_mode, + ) + except Exception as e: + logger.warning( + "Failed to update platform for node {}: {}", + self._node_id, + format_exception_for_log( + e, log_full_message=self._log_messaging_error_details + ), + ) diff --git a/src/free_claude_code/messaging/voice.py b/src/free_claude_code/messaging/voice.py new file mode 100644 index 0000000..a52a974 --- /dev/null +++ b/src/free_claude_code/messaging/voice.py @@ -0,0 +1,376 @@ +"""Platform-neutral voice note helpers.""" + +import asyncio +from collections.abc import Awaitable, Callable +from contextvars import ContextVar +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Protocol +from uuid import uuid4 + +from .models import MessageScope + + +async def _await_owned_task[T]( + task: asyncio.Task[T], +) -> tuple[T, asyncio.CancelledError | None]: + """Finish an owned task before returning any caller cancellation.""" + cancellation: asyncio.CancelledError | None = None + current = asyncio.current_task() + while True: + cancelling_before = current.cancelling() if current is not None else 0 + try: + return await asyncio.shield(task), cancellation + except asyncio.CancelledError as exc: + if current is None or ( + current.cancelling() <= cancelling_before and task.done() + ): + raise + cancellation = cancellation or exc + + +class Transcriber(Protocol): + """Consumer-owned voice transcription boundary.""" + + async def transcribe(self, file_path: Path) -> str: ... + + async def close(self) -> None: ... + + +@dataclass(frozen=True, slots=True) +class PendingVoiceClaim: + """Opaque ownership token for one pending voice-note generation.""" + + scope: MessageScope + voice_message_id: str + claim_id: str + + +_current_voice_claim: ContextVar[PendingVoiceClaim | None] = ContextVar( + "current_voice_claim", + default=None, +) + + +class VoiceHandoffOutcome(Enum): + """Exclusive outcome of publishing one transcribed voice message.""" + + REJECTED = "rejected" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +@dataclass(frozen=True, slots=True) +class VoiceCancellationResult: + """Released ownership for one successfully cancelled user voice note.""" + + scope: MessageScope + voice_message_id: str + status_message_id: str | None + delete_message_ids: frozenset[str] + + +@dataclass(slots=True) +class _PendingVoice: + claim: PendingVoiceClaim + status_message_id: str | None = None + handoff_task: asyncio.Task[None] | None = None + + +class PendingVoiceRegistry: + """Own atomic reservation, cancellation, and handoff of voice notes.""" + + def __init__(self) -> None: + self._pending: dict[tuple[MessageScope, str], _PendingVoice] = {} + self._lock = asyncio.Lock() + self._active_cancellations: dict[PendingVoiceClaim, int] = {} + + async def reserve( + self, + scope: MessageScope, + voice_message_id: str, + ) -> PendingVoiceClaim | None: + async with self._lock: + key = (scope, voice_message_id) + if key in self._pending: + return None + claim = PendingVoiceClaim( + scope=scope, + voice_message_id=voice_message_id, + claim_id=uuid4().hex, + ) + self._pending[key] = _PendingVoice(claim=claim) + return claim + + async def bind_status( + self, + claim: PendingVoiceClaim, + status_message_id: str, + ) -> bool: + async with self._lock: + entry = self._entry_for_claim(claim) + if entry is None: + return False + if entry.status_message_id is not None: + return entry.status_message_id == status_message_id + status_key = (claim.scope, status_message_id) + existing = self._pending.get(status_key) + if existing is not None and existing is not entry: + return False + entry.status_message_id = status_message_id + self._pending[status_key] = entry + return True + + async def handoff( + self, + claim: PendingVoiceClaim, + callback: Callable[[], Awaitable[None]], + ) -> VoiceHandoffOutcome: + """Run a published handoff while retaining its cancellable ownership.""" + async with self._lock: + entry = self._entry_for_claim(claim) + if ( + entry is None + or entry.status_message_id is None + or entry.handoff_task is not None + ): + return VoiceHandoffOutcome.REJECTED + task = asyncio.create_task( + self._run_callback(claim, callback), + name=f"voice-handoff-{claim.claim_id}", + ) + entry.handoff_task = task + + current = asyncio.current_task() + cancelling_before = current.cancelling() if current is not None else 0 + try: + await asyncio.shield(task) + except BaseException as error: + caller_cancelled = ( + isinstance(error, asyncio.CancelledError) + and current is not None + and (current.cancelling() > cancelling_before or not task.done()) + ) + if caller_cancelled: + task.cancel() + child_error: BaseException | None = None + try: + await self._drain((task,)) + except BaseException as drained_error: + child_error = drained_error + await self._finish_ownership(entry) + if child_error is not None: + raise child_error from None + raise error from None + + completed, cancellation = await self._finish_ownership(entry) + if cancellation is not None and not self._is_fatal(error): + raise cancellation from None + if completed or self._is_fatal(error): + raise error + return VoiceHandoffOutcome.CANCELLED + + completed, cancellation = await self._finish_ownership(entry) + if cancellation is not None: + raise cancellation + if completed: + return VoiceHandoffOutcome.COMPLETED + return VoiceHandoffOutcome.CANCELLED + + async def discard(self, claim: PendingVoiceClaim) -> bool: + async with self._lock: + entry = self._entry_for_claim(claim) + if entry is None: + return False + self._remove(entry) + task = entry.handoff_task + cancellation = await self._cancel_and_drain(task) + if cancellation is not None: + raise cancellation + return True + + async def cancel( + self, scope: MessageScope, reply_id: str + ) -> VoiceCancellationResult | None: + current_claim = _current_voice_claim.get() + self._protect_claim(current_claim) + try: + async with self._lock: + entry = self._pending.get((scope, reply_id)) + if entry is None or self._is_excluded(entry, current_claim): + return None + self._remove(entry) + task = entry.handoff_task + result = self._cancellation_result(entry, reply_id) + cancellation = await self._cancel_and_drain(task) + if cancellation is not None: + raise cancellation + return result + finally: + self._unprotect_claim(current_claim) + + async def cancel_all(self) -> tuple[VoiceCancellationResult, ...]: + """Cancel every unique pending voice note and drain published handoffs.""" + return await self._cancel_matching_scope(None) + + async def cancel_scope( + self, scope: MessageScope + ) -> tuple[VoiceCancellationResult, ...]: + """Cancel every unique pending voice note in one platform chat.""" + return await self._cancel_matching_scope(scope) + + async def _cancel_matching_scope( + self, + scope: MessageScope | None, + ) -> tuple[VoiceCancellationResult, ...]: + current_claim = _current_voice_claim.get() + self._protect_claim(current_claim) + try: + async with self._lock: + entries = tuple( + { + entry.claim: entry + for (entry_scope, _reference_id), entry in self._pending.items() + if (scope is None or entry_scope == scope) + and not self._is_excluded(entry, current_claim) + }.values() + ) + for entry in entries: + self._remove(entry) + + tasks = tuple( + task for entry in entries if (task := entry.handoff_task) is not None + ) + for task in tasks: + task.cancel() + cancellation = await self._drain(tasks) + if cancellation is not None: + raise cancellation + return tuple(self._cancellation_result(entry) for entry in entries) + finally: + self._unprotect_claim(current_claim) + + async def _finish_ownership( + self, + entry: _PendingVoice, + ) -> tuple[bool, asyncio.CancelledError | None]: + finish_task = asyncio.create_task( + self._complete_if_owned(entry), + name=f"voice-handoff-finish-{entry.claim.claim_id}", + ) + return await _await_owned_task(finish_task) + + async def _complete_if_owned(self, entry: _PendingVoice) -> bool: + async with self._lock: + if self._entry_for_claim(entry.claim) is not entry: + return False + self._remove(entry) + return True + + @staticmethod + async def _run_callback( + claim: PendingVoiceClaim, + callback: Callable[[], Awaitable[None]], + ) -> None: + token = _current_voice_claim.set(claim) + try: + await callback() + finally: + _current_voice_claim.reset(token) + + @staticmethod + async def _cancel_and_drain( + task: asyncio.Task[None] | None, + ) -> asyncio.CancelledError | None: + if task is None or task is asyncio.current_task(): + return None + task.cancel() + return await PendingVoiceRegistry._drain((task,)) + + @staticmethod + async def _drain( + tasks: tuple[asyncio.Task[None], ...], + ) -> asyncio.CancelledError | None: + if not tasks: + return None + drain_task = asyncio.create_task( + PendingVoiceRegistry._consume_results(tasks), + name="voice-handoff-drain", + ) + _, cancellation = await _await_owned_task(drain_task) + return cancellation + + @staticmethod + async def _consume_results(tasks: tuple[asyncio.Task[None], ...]) -> None: + fatal_error: BaseException | None = None + for task in tasks: + try: + await task + except asyncio.CancelledError, Exception: + continue + except BaseException as error: + fatal_error = fatal_error or error + if fatal_error is not None: + raise fatal_error + + @staticmethod + def _is_fatal(error: BaseException) -> bool: + return not isinstance(error, (asyncio.CancelledError, Exception)) + + @staticmethod + def _cancellation_result( + entry: _PendingVoice, + reference_id: str | None = None, + ) -> VoiceCancellationResult: + delete_message_ids = {entry.claim.voice_message_id} + if reference_id is not None and reference_id != entry.claim.voice_message_id: + delete_message_ids.clear() + if entry.status_message_id is not None: + delete_message_ids.add(entry.status_message_id) + return VoiceCancellationResult( + scope=entry.claim.scope, + voice_message_id=entry.claim.voice_message_id, + status_message_id=entry.status_message_id, + delete_message_ids=frozenset(delete_message_ids), + ) + + def _entry_for_claim(self, claim: PendingVoiceClaim) -> _PendingVoice | None: + entry = self._pending.get((claim.scope, claim.voice_message_id)) + if entry is None or entry.claim != claim: + return None + return entry + + def _is_excluded( + self, + entry: _PendingVoice, + current_claim: PendingVoiceClaim | None, + ) -> bool: + return ( + entry.claim == current_claim + or self._active_cancellations.get(entry.claim, 0) > 0 + ) + + def _protect_claim(self, claim: PendingVoiceClaim | None) -> None: + if claim is None: + return + self._active_cancellations[claim] = self._active_cancellations.get(claim, 0) + 1 + + def _unprotect_claim(self, claim: PendingVoiceClaim | None) -> None: + if claim is None: + return + remaining = self._active_cancellations[claim] - 1 + if remaining: + self._active_cancellations[claim] = remaining + else: + self._active_cancellations.pop(claim) + + def _remove(self, entry: _PendingVoice) -> None: + voice_key = (entry.claim.scope, entry.claim.voice_message_id) + if self._pending.get(voice_key) is entry: + self._pending.pop(voice_key) + if entry.status_message_id is None: + return + status_key = (entry.claim.scope, entry.status_message_id) + if self._pending.get(status_key) is entry: + self._pending.pop(status_key) diff --git a/src/free_claude_code/messaging/workflow.py b/src/free_claude_code/messaging/workflow.py new file mode 100644 index 0000000..f020036 --- /dev/null +++ b/src/free_claude_code/messaging/workflow.py @@ -0,0 +1,714 @@ +"""Messaging workflow coordinator for Discord and Telegram prompts.""" + +import asyncio +from collections.abc import Coroutine +from typing import Any + +from loguru import logger + +from free_claude_code.core.trace import trace_event + +from .command_context import ReplyClearResult, StopOutcome +from .command_dispatcher import parse_command_base +from .managed_protocols import ManagedClaudeSessionManagerProtocol +from .models import AdmissionToken, IncomingMessage, MessageScope +from .node_runner import MessagingNodeRunner +from .platforms.ports import ( + MessagingStartupNotice, + OutboundMessenger, + VoiceCancellation, +) +from .rendering.profiles import build_rendering_profile +from .safe_diagnostics import format_exception_for_log +from .session import SessionStore +from .transcript import RenderCtx +from .trees import ( + CancellationReason, + CancellationResult, + CancellationUiOwner, + ConversationSnapshot, + FailureResult, + NodeUiTarget, + QueueDecision, + ReplyTarget, + TreeQueueManager, +) +from .turn_intake import MessagingTurnIntake +from .voice import VoiceCancellationResult + + +async def _finish_owned_operation[T]( + operation: Coroutine[Any, Any, T], + *, + name: str, +) -> T: + """Finish owned work, preserving its failures before caller cancellation.""" + task = asyncio.create_task(operation, name=name) + current = asyncio.current_task() + cancellation: asyncio.CancelledError | None = None + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError as exc: + if current is not None and current.cancelling(): + cancellation = cancellation or exc + except Exception: + break + + # Task.result() raises the owned operation's failure. Caller cancellation + # is restored only after successful completion. + result = task.result() + if cancellation is not None: + raise cancellation + return result + + +def _stop_outcome( + voices: tuple[VoiceCancellationResult, ...], + cancellation: CancellationResult, +) -> StopOutcome: + """Summarize distinct stopped owners and whether existing status UI covers them.""" + status_coverage: dict[tuple[MessageScope, str], bool] = {} + for voice in voices: + key = (voice.scope, voice.voice_message_id) + status_coverage[key] = ( + status_coverage.get(key, False) or voice.status_message_id is not None + ) + for effect in cancellation.effects: + status_coverage[(effect.node.scope, effect.node.node_id)] = True + return StopOutcome( + cancelled_count=len(status_coverage), + status_feedback_scopes=frozenset( + scope for (scope, _owner_id), covered in status_coverage.items() if covered + ), + fallback_required=any(not covered for covered in status_coverage.values()), + ) + + +class MessagingWorkflow: + """Own messaging state transitions and their external side effects.""" + + def __init__( + self, + outbound: OutboundMessenger, + cli_manager: ManagedClaudeSessionManagerProtocol, + session_store: SessionStore, + *, + platform_name: str | None = None, + voice_cancellation: VoiceCancellation | None = None, + debug_platform_edits: bool = False, + debug_subagent_stack: bool = False, + log_raw_cli_diagnostics: bool = False, + log_messaging_error_details: bool = False, + ) -> None: + self.platform_name = platform_name or "messaging" + self.outbound = outbound + self.voice_cancellation = voice_cancellation + self.cli_manager = cli_manager + self.session_store = session_store + self._log_messaging_error_details = log_messaging_error_details + self._rendering_profile = build_rendering_profile(self.platform_name) + self._state_lock = asyncio.Lock() + self._stop_generation = 0 + self._clear_generations: dict[MessageScope, int] = {} + self._pending_restored_status_targets: tuple[NodeUiTarget, ...] = () + + self._tree_queue: TreeQueueManager + self.node_runner = MessagingNodeRunner( + platform_name=self.platform_name, + outbound=outbound, + cli_manager=cli_manager, + session_store=session_store, + get_tree_queue=lambda: self._tree_queue, + format_status=self.format_status, + get_parse_mode=self._parse_mode, + get_render_ctx=self.get_render_ctx, + get_limit_chars=self._get_limit_chars, + debug_platform_edits=debug_platform_edits, + debug_subagent_stack=debug_subagent_stack, + log_raw_cli_diagnostics=log_raw_cli_diagnostics, + log_messaging_error_details=log_messaging_error_details, + ) + self.turn_intake = MessagingTurnIntake( + platform_name=self.platform_name, + outbound=outbound, + session_store=session_store, + command_context=self, + resolve_reply=self.resolve_reply, + admit_turn=self._admit_turn_if_current, + format_status=self.format_status, + get_parse_mode=self._parse_mode, + record_outgoing_message=self.record_outgoing_message, + ) + self._tree_queue = self._build_tree_queue() + + def _build_tree_queue( + self, snapshot: ConversationSnapshot | None = None + ) -> TreeQueueManager: + if snapshot is None: + return TreeQueueManager( + self.node_runner.process_node, + queue_update_callback=self.turn_intake.update_queue_positions, + node_started_callback=self.turn_intake.mark_node_processing, + unexpected_failure_callback=self._apply_unexpected_failure, + log_messaging_error_details=self._log_messaging_error_details, + ) + return TreeQueueManager.from_snapshot( + snapshot, + self.node_runner.process_node, + queue_update_callback=self.turn_intake.update_queue_positions, + node_started_callback=self.turn_intake.mark_node_processing, + unexpected_failure_callback=self._apply_unexpected_failure, + log_messaging_error_details=self._log_messaging_error_details, + ) + + def format_status(self, emoji: str, label: str, suffix: str | None = None) -> str: + return self._rendering_profile.format_status(emoji, label, suffix) + + def _parse_mode(self) -> str | None: + return self._rendering_profile.parse_mode + + def get_render_ctx(self) -> RenderCtx: + return self._rendering_profile.render_ctx + + def _get_limit_chars(self) -> int: + return self._rendering_profile.limit_chars + + @property + def tree_queue(self) -> TreeQueueManager: + """Expose the manager facade for diagnostics and smoke tests.""" + return self._tree_queue + + def restore(self) -> None: + """Restore and reconcile persisted conversations before platform start.""" + snapshot = self.session_store.load_conversation_snapshot() + if snapshot.is_empty: + return + logger.info("Restoring {} conversation trees...", len(snapshot.trees)) + self._tree_queue = self._build_tree_queue(snapshot) + normalized = self._tree_queue.restored_snapshot + if normalized is not None and normalized != snapshot: + self.session_store.save_conversation_snapshot(normalized) + self._pending_restored_status_targets = self._tree_queue.restored_stale_targets + + async def repair_restored_statuses(self) -> None: + """Replace stale queued/processing UI after delivery becomes available.""" + targets = self._pending_restored_status_targets + self._pending_restored_status_targets = () + for target in targets: + if self.platform_name != "messaging" and ( + target.scope.platform != self.platform_name + ): + continue + try: + await self.outbound.queue_edit_message( + target.scope.chat_id, + target.status_message_id, + self.format_status("❌", "Interrupted by server restart"), + parse_mode=self._parse_mode(), + fire_and_forget=False, + ) + except Exception as exc: + logger.debug( + "Failed to repair restored status for node {}: {}", + target.node_id, + type(exc).__name__, + ) + + async def publish_startup_notice(self, notice: MessagingStartupNotice) -> None: + """Publish one notice, then transfer its receipt to clear ownership.""" + scope = MessageScope(platform=self.platform_name, chat_id=notice.chat_id) + async with self._state_lock: + clear_generation = self._clear_generations.get(scope, 0) + + try: + message_id = await self.outbound.queue_send_message( + notice.chat_id, + self.format_status( + "🚀", + "Claude Code Proxy is online!", + f"({notice.transport_label})", + ), + parse_mode=self._parse_mode(), + fire_and_forget=False, + ) + except Exception as exc: + logger.warning( + "Could not publish messaging startup notice: {}", + format_exception_for_log( + exc, + log_full_message=self._log_messaging_error_details, + ), + ) + return + + if not message_id: + return + + publisher_task = asyncio.current_task() + await _finish_owned_operation( + self._finalize_startup_notice( + notice, + message_id, + clear_generation, + publisher_task, + ), + name="messaging-finalize-startup-notice", + ) + + async def _finalize_startup_notice( + self, + notice: MessagingStartupNotice, + message_id: str, + clear_generation: int, + publisher_task: asyncio.Task[Any] | None, + ) -> None: + """Commit one delivery receipt or compensate it outside the state lock.""" + async with self._state_lock: + must_discard = ( + publisher_task is not None and publisher_task.cancelling() > 0 + ) or clear_generation != self._clear_generations.get( + MessageScope(platform=self.platform_name, chat_id=notice.chat_id), 0 + ) + if not must_discard: + must_discard = not self.record_outgoing_message( + self.platform_name, + notice.chat_id, + message_id, + "startup", + ) + + if must_discard: + await self._discard_startup_notice(notice.chat_id, message_id) + + async def _discard_startup_notice( + self, + chat_id: str, + message_id: str, + ) -> None: + """Delete an uncommitted notice or retain its ID for a later clear.""" + try: + await self.outbound.queue_delete_messages( + chat_id, + [message_id], + fire_and_forget=False, + ) + except Exception as exc: + logger.warning( + "Could not discard messaging startup notice: {}", + format_exception_for_log( + exc, + log_full_message=self._log_messaging_error_details, + ), + ) + async with self._state_lock: + tracked = self.record_outgoing_message( + self.platform_name, + chat_id, + message_id, + "startup", + ) + if not tracked: + logger.warning( + "Messaging startup notice could neither be deleted nor tracked" + ) + return + + async with self._state_lock: + self.forget_tracked_message_ids( + self.platform_name, + chat_id, + {message_id}, + ) + + async def close(self) -> None: + """Finish every owned task and durable write before releasing delivery.""" + await self.stop_all_tasks() + await self._tree_queue.wait_idle() + self.session_store.flush_pending_save() + + async def handle_message(self, incoming: IncomingMessage) -> None: + """Handle one platform message.""" + trace_event( + stage="ingress", + event="turn.received", + source=self.platform_name, + chat_id=incoming.chat_id, + platform_message_id=incoming.message_id, + reply_to_message_id=incoming.reply_to_message_id, + thread_id=incoming.message_thread_id, + message_text=incoming.text or "", + ) + with logger.contextualize( + chat_id=incoming.chat_id, + node_id=incoming.message_id, + ): + is_standalone_clear = ( + parse_command_base(incoming.text) == "/clear" + and not incoming.is_reply() + ) + async with self._state_lock: + admission_token = AdmissionToken( + stop_generation=self._stop_generation, + clear_generation=self._clear_generations.get(incoming.scope, 0), + ) + if not is_standalone_clear: + self._record_incoming_message(incoming) + try: + await self.turn_intake.handle_message( + incoming, + admission_token=admission_token, + ) + except BaseException: + if is_standalone_clear: + async with self._state_lock: + self._record_incoming_message(incoming) + raise + + async def resolve_reply( + self, + scope: MessageScope, + reference_id: str, + ) -> ReplyTarget | None: + return await self._tree_queue.resolve_reply(scope, reference_id) + + async def _admit_turn_if_current( + self, + incoming: IncomingMessage, + status_message_id: str, + parent_reference_id: str | None, + admission_token: AdmissionToken, + ) -> QueueDecision | None: + return await _finish_owned_operation( + self._admit_if_current( + incoming, + status_message_id, + parent_reference_id, + admission_token, + ), + name=f"messaging-admit-{incoming.message_id}", + ) + + async def _admit_if_current( + self, + incoming: IncomingMessage, + status_message_id: str, + parent_reference_id: str | None, + admission_token: AdmissionToken, + ) -> QueueDecision | None: + """Commit admission and its exact snapshot as one owned transaction.""" + async with self._state_lock: + current_token = AdmissionToken( + stop_generation=self._stop_generation, + clear_generation=self._clear_generations.get(incoming.scope, 0), + ) + if admission_token != current_token: + return None + decision = await self._tree_queue.admit( + incoming, + status_message_id, + parent_reference_id=parent_reference_id, + ) + if decision.snapshot is not None: + self.session_store.save_tree_snapshot(decision.snapshot) + return decision + + def get_tree_count(self) -> int: + return self._tree_queue.get_tree_count() + + async def stop_reply( + self, + scope: MessageScope, + reply_id: str, + ) -> StopOutcome: + """Stop the exact voice/tree owner of one replied-to message.""" + return await _finish_owned_operation( + self._stop_reply(scope, reply_id), + name=f"messaging-stop-reply-{reply_id}", + ) + + async def _stop_reply( + self, + scope: MessageScope, + reply_id: str, + ) -> StopOutcome: + voice_result = await self._cancel_pending_voice(scope, reply_id) + if voice_result is not None: + self.render_voice_stopped(voice_result) + + async with self._state_lock: + node_id = await self._tree_queue.resolve_node_id(scope, reply_id) + if node_id is None: + voices = (voice_result,) if voice_result is not None else () + return _stop_outcome(voices, CancellationResult()) + result = await self._tree_queue.cancel_node( + scope, + node_id, + reason=CancellationReason.STOP, + ) + self._apply_cancellation_result(result) + + voices = (voice_result,) if voice_result is not None else () + return _stop_outcome(voices, result) + + async def clear_reply( + self, + scope: MessageScope, + reply_id: str, + ) -> ReplyClearResult | None: + """Clear the exact voice/tree owner of one replied-to message.""" + return await _finish_owned_operation( + self._clear_reply(scope, reply_id), + name=f"messaging-clear-reply-{reply_id}", + ) + + async def _clear_reply( + self, + scope: MessageScope, + reply_id: str, + ) -> ReplyClearResult | None: + voice_result = await self._cancel_pending_voice(scope, reply_id) + + async with self._state_lock: + subtree = await self._tree_queue.remove_message_subtree( + scope, + reply_id, + reason=CancellationReason.CLEAR, + ) + if not subtree.tree_matched and voice_result is None: + return None + self._save_cancellation_snapshots(subtree.cancellation) + if subtree.removed_tree_identity is not None: + self.session_store.remove_tree_snapshot(subtree.removed_tree_identity) + + delete_message_ids = set(subtree.delete_message_ids) + if voice_result is not None: + delete_message_ids.update(voice_result.delete_message_ids) + return ReplyClearResult( + delete_message_ids=frozenset(delete_message_ids), + tree_matched=subtree.tree_matched, + ) + + async def stop_all_tasks(self) -> StopOutcome: + """Stop every pending and active messaging task.""" + return await _finish_owned_operation( + self._stop_all_tasks(), + name="messaging-stop-all", + ) + + async def _stop_all_tasks(self) -> StopOutcome: + voice_results = await self._cancel_all_pending_voices() + for voice in voice_results: + self.render_voice_stopped(voice) + async with self._state_lock: + self._stop_generation += 1 + logger.info("Cancelling tree queue tasks...") + result = await self._tree_queue.cancel_all(reason=CancellationReason.STOP) + logger.info("Cancelled {} nodes", len(result.effects)) + self._apply_cancellation_result(result) + logger.info("Stopping all CLI sessions...") + await self.cli_manager.stop_all() + return _stop_outcome(voice_results, result) + + async def clear_chat(self, platform: str, chat_id: str) -> frozenset[str]: + """Clear FCC state atomically with respect to later turn admission.""" + return await _finish_owned_operation( + self._clear_chat(platform, chat_id), + name=f"messaging-clear-{platform}-{chat_id}", + ) + + async def _clear_chat( + self, + platform: str, + chat_id: str, + ) -> frozenset[str]: + """Reset one chat's FCC state and return all tracked deletion IDs.""" + clear_scope = MessageScope(platform=platform, chat_id=chat_id) + voice_results = await self._cancel_pending_voices_in_scope(clear_scope) + async with self._state_lock: + delete_message_ids: set[str] = set() + for voice in voice_results: + delete_message_ids.update(voice.delete_message_ids) + delete_message_ids.update( + self.session_store.get_tracked_message_ids_for_chat(platform, chat_id) + ) + + delete_message_ids.update( + await self._tree_queue.get_message_ids_for_chat(platform, chat_id) + ) + # All fallible/cancellable reads precede the commit boundary. Once + # the scope generation advances, state removal is one-way work. + self._clear_generations[clear_scope] = ( + self._clear_generations.get(clear_scope, 0) + 1 + ) + failures: list[Exception] = [] + try: + await self._tree_queue.clear_scope( + clear_scope, + reason=CancellationReason.CLEAR, + ) + except Exception as exc: + failures.append(exc) + try: + self.session_store.clear_scope(clear_scope) + except Exception as exc: + failures.append(exc) + logger.warning( + "Failed to persist final cleared session state: {}", + type(exc).__name__, + ) + if len(failures) == 1: + raise failures[0] + if failures: + raise ExceptionGroup("Chat clear failed", failures) + return frozenset(delete_message_ids) + + async def _cancel_all_pending_voices( + self, + ) -> tuple[VoiceCancellationResult, ...]: + cancellation = self.voice_cancellation + if cancellation is None: + return () + return await cancellation.cancel_all_pending_voices() + + async def _cancel_pending_voices_in_scope( + self, + scope: MessageScope, + ) -> tuple[VoiceCancellationResult, ...]: + cancellation = self.voice_cancellation + if cancellation is None: + return () + return await cancellation.cancel_pending_voices_in_scope(scope) + + async def _cancel_pending_voice( + self, + scope: MessageScope, + reply_id: str, + ) -> VoiceCancellationResult | None: + cancellation = self.voice_cancellation + if cancellation is None: + return None + return await cancellation.cancel_pending_voice(scope, reply_id) + + def render_voice_stopped(self, result: VoiceCancellationResult) -> None: + """Publish terminal UI for a voice cancelled before tree ownership.""" + if result.status_message_id is None: + return + self.outbound.fire_and_forget( + self.outbound.queue_edit_message( + result.scope.chat_id, + result.status_message_id, + self.format_status("⏹", "Stopped."), + parse_mode=self._parse_mode(), + ) + ) + + def forget_tracked_message_ids( + self, + platform: str, + chat_id: str, + message_ids: set[str], + ) -> None: + try: + self.session_store.forget_tracked_message_ids( + platform, chat_id, message_ids + ) + except Exception as exc: + logger.warning( + "Failed to update managed-message log after clear: {}", + type(exc).__name__, + ) + + def record_outgoing_message( + self, + platform: str, + chat_id: str, + msg_id: str | None, + kind: str, + ) -> bool: + """Record an outgoing message ID for /clear and report ownership.""" + if not msg_id: + return False + try: + self.session_store.record_message_id( + platform, + chat_id, + str(msg_id), + "out", + kind, + ) + except Exception as exc: + logger.debug( + "Failed to record message_id: {}", + format_exception_for_log( + exc, + log_full_message=self._log_messaging_error_details, + ), + ) + return False + return True + + def _record_incoming_message(self, incoming: IncomingMessage) -> bool: + """Record an inbound prompt, voice note, or command for standalone clear.""" + command = parse_command_base(incoming.text) + kind = ( + "command" + if command.startswith("/") + else "voice" + if incoming.status_message_id is not None + else "prompt" + ) + try: + self.session_store.record_message_id( + incoming.platform, + incoming.chat_id, + str(incoming.message_id), + "in", + kind, + ) + except Exception as exc: + logger.debug( + "Failed to record managed inbound message_id: {}", + format_exception_for_log( + exc, + log_full_message=self._log_messaging_error_details, + ), + ) + return False + return True + + def _save_cancellation_snapshots(self, result: CancellationResult) -> None: + """Persist transition snapshots without publishing cancellation UI.""" + for snapshot in result.snapshots: + self.session_store.save_tree_snapshot(snapshot) + + def _apply_cancellation_result(self, result: CancellationResult) -> None: + """Apply detached UI and persistence effects from one transition.""" + for effect in result.effects: + if effect.ui_owner is CancellationUiOwner.WORKFLOW: + self.outbound.fire_and_forget( + self.outbound.queue_edit_message( + effect.node.scope.chat_id, + effect.node.status_message_id, + self.format_status("⏹", "Stopped."), + parse_mode=self._parse_mode(), + ) + ) + self._save_cancellation_snapshots(result) + + def _apply_unexpected_failure(self, result: FailureResult) -> None: + """Persist and render a failure that escaped the total node runner.""" + if result.snapshot is not None: + self.session_store.save_tree_snapshot(result.snapshot) + for target in result.affected: + self.outbound.fire_and_forget( + self.outbound.queue_edit_message( + target.scope.chat_id, + target.status_message_id, + self.format_status("💥", "Task Failed"), + parse_mode=self._parse_mode(), + ) + ) + + +__all__ = ["MessagingWorkflow"] diff --git a/src/free_claude_code/providers/__init__.py b/src/free_claude_code/providers/__init__.py new file mode 100644 index 0000000..773a170 --- /dev/null +++ b/src/free_claude_code/providers/__init__.py @@ -0,0 +1,12 @@ +"""Shared provider lifecycle contracts. + +Ordinary OpenAI-compatible vendors are immutable profiles. Concrete adapter +classes exist only for providers with stateful or algorithmic behavior. +""" + +from .base import BaseProvider, ProviderConfig + +__all__ = [ + "BaseProvider", + "ProviderConfig", +] diff --git a/src/free_claude_code/providers/base.py b/src/free_claude_code/providers/base.py new file mode 100644 index 0000000..a7dd4b9 --- /dev/null +++ b/src/free_claude_code/providers/base.py @@ -0,0 +1,136 @@ +"""Base provider interface - extend this to implement your own provider.""" + +from abc import ABC, abstractmethod +from collections.abc import AsyncIterator +from dataclasses import dataclass + +from loguru import logger + +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.constants import HTTP_CONNECT_TIMEOUT_DEFAULT +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.diagnostics import ( + exception_cause_types, + redacted_exception_traceback, +) +from free_claude_code.core.trace import trace_event +from free_claude_code.providers.model_listing import model_infos_from_ids + + +@dataclass(frozen=True, slots=True) +class ProviderConfig: + """Resolved immutable configuration for one provider instance. + + Base fields apply to all providers. Provider-specific parameters + (e.g. NIM temperature, top_p) are passed by the provider constructor. + """ + + api_key: str + base_url: str + rate_limit: int | None = None + rate_window: int = 60 + max_concurrency: int = 5 + http_read_timeout: float = 300.0 + http_write_timeout: float = 10.0 + http_connect_timeout: float = HTTP_CONNECT_TIMEOUT_DEFAULT + enable_thinking: bool = True + proxy: str = "" + log_raw_sse_events: bool = False + log_api_error_tracebacks: bool = False + + +class BaseProvider(ABC): + """Base class for all providers. Extend this to add your own.""" + + def __init__(self, config: ProviderConfig): + self._config = config + + def _is_thinking_enabled( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> bool: + """Return whether thinking should be enabled for this request.""" + thinking = request.thinking + config_enabled = ( + self._config.enable_thinking + if thinking_enabled is None + else thinking_enabled + ) + request_enabled = True + if thinking is not None: + if "enabled" in thinking.model_fields_set and thinking.enabled is not None: + request_enabled = thinking.enabled + if thinking.type == "disabled": + request_enabled = False + return config_enabled and request_enabled + + @abstractmethod + def preflight_stream( + self, request: MessagesRequest, *, thinking_enabled: bool | None = None + ) -> None: + """Validate the upstream request before opening an SSE stream.""" + + def _log_stream_transport_error( + self, + tag: str, + req_tag: str, + error: Exception, + *, + request_id: str | None = None, + ) -> None: + """Log streaming transport failures (metadata-only unless verbose is enabled).""" + response = getattr(error, "response", None) + http_status = ( + getattr(response, "status_code", None) if response is not None else None + ) + cause_types = exception_cause_types(error) + trace_event( + stage="provider", + event="provider.response.transport_error", + source="provider", + provider=tag, + request_id=request_id, + exc_type=type(error).__name__, + http_status=http_status, + cause_types=cause_types, + ) + + if self._config.log_api_error_tracebacks: + logger.error( + "{}_ERROR:{} exc_type={}\n{}", + tag, + req_tag, + type(error).__name__, + redacted_exception_traceback(error), + ) + return + logger.error( + "{}_ERROR:{} exc_type={} http_status={} cause_types={}", + tag, + req_tag, + type(error).__name__, + http_status, + ",".join(cause_types) if cause_types else None, + ) + + @abstractmethod + async def cleanup(self) -> None: + """Release any resources held by this provider.""" + + @abstractmethod + async def list_model_ids(self) -> frozenset[str]: + """Return the model ids currently advertised by this provider.""" + + async def list_model_infos(self) -> frozenset[ProviderModelInfo]: + """Return advertised model ids with optional provider capability metadata.""" + return model_infos_from_ids(await self.list_model_ids()) + + @abstractmethod + def stream_response( + self, + request: MessagesRequest, + input_tokens: int = 0, + *, + request_id: str | None = None, + thinking_enabled: bool | None = None, + ) -> AsyncIterator[str]: + """Stream response in Anthropic SSE format.""" diff --git a/src/free_claude_code/providers/cloudflare/__init__.py b/src/free_claude_code/providers/cloudflare/__init__.py new file mode 100644 index 0000000..ca87af6 --- /dev/null +++ b/src/free_claude_code/providers/cloudflare/__init__.py @@ -0,0 +1,8 @@ +"""Cloudflare AI REST provider package.""" + +from .client import CloudflareProvider, cloudflare_ai_base_url + +__all__ = ( + "CloudflareProvider", + "cloudflare_ai_base_url", +) diff --git a/src/free_claude_code/providers/cloudflare/client.py b/src/free_claude_code/providers/cloudflare/client.py new file mode 100644 index 0000000..d37920d --- /dev/null +++ b/src/free_claude_code/providers/cloudflare/client.py @@ -0,0 +1,168 @@ +"""Cloudflare Workers AI provider using OpenAI-compatible chat completions.""" + +from collections.abc import Iterator, Mapping +from dataclasses import replace +from typing import Any +from urllib.parse import quote + +import httpx + +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.provider_catalog import CLOUDFLARE_AI_REST_ROOT +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.http import maybe_await_aclose +from free_claude_code.providers.model_listing import ( + ModelListResponseError, + extract_openai_model_ids, + model_infos_from_ids, +) +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, + build_openai_chat_request_body, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +_REQUEST_POLICY = OpenAIChatRequestPolicy( + provider_name="CLOUDFLARE", + include_extra_body=True, + max_tokens_field="max_completion_tokens", +) +_PROFILE = OpenAIChatProfile(_REQUEST_POLICY) + + +def cloudflare_ai_base_url(api_root: str | None, account_id: str) -> str: + """Return the account-scoped Cloudflare Workers AI OpenAI-compatible base URL.""" + + return f"{_cloudflare_account_api_url(api_root, account_id)}/ai/v1" + + +def _cloudflare_model_search_url(api_root: str | None, account_id: str) -> str: + """Return the Cloudflare account model-search endpoint URL.""" + + return f"{_cloudflare_account_api_url(api_root, account_id)}/ai/models/search" + + +def _cloudflare_account_api_url(api_root: str | None, account_id: str) -> str: + """Return the account-scoped Cloudflare API root URL.""" + + stripped_account = account_id.strip() + if not stripped_account: + raise ApplicationUnavailableError( + "CLOUDFLARE_ACCOUNT_ID is not set. Add it to your .env file." + ) + root = (api_root or CLOUDFLARE_AI_REST_ROOT).rstrip("/") + encoded_account = quote(stripped_account, safe="") + return f"{root}/accounts/{encoded_account}" + + +class CloudflareProvider(OpenAIChatProvider): + """Cloudflare Workers AI OpenAI-compatible chat provider.""" + + def __init__( + self, + config: ProviderConfig, + *, + account_id: str, + rate_limiter: ProviderRateLimiter, + ): + base_url = cloudflare_ai_base_url(config.base_url, account_id) + self._model_search_url = _cloudflare_model_search_url( + config.base_url, account_id + ) + self._model_list_client = httpx.AsyncClient( + proxy=config.proxy or None, + timeout=httpx.Timeout( + config.http_read_timeout, + connect=config.http_connect_timeout, + read=config.http_read_timeout, + write=config.http_write_timeout, + ), + ) + super().__init__( + replace(config, base_url=base_url), + profile=_PROFILE, + rate_limiter=rate_limiter, + ) + + async def cleanup(self) -> None: + """Release provider client resources.""" + await super().cleanup() + await self._model_list_client.aclose() + + async def list_model_ids(self) -> frozenset[str]: + """Return Cloudflare Workers AI model ids from account model search.""" + return frozenset(info.model_id for info in await self.list_model_infos()) + + async def list_model_infos(self) -> frozenset[ProviderModelInfo]: + """Return Cloudflare Workers AI model ids from account model search.""" + response = await self._model_list_client.get( + self._model_search_url, + params={"format": "openrouter"}, + headers=self._model_list_headers(), + ) + try: + response.raise_for_status() + try: + payload = response.json() + except ValueError as exc: + raise ModelListResponseError( + "CLOUDFLARE model-list response is malformed: invalid JSON" + ) from exc + return model_infos_from_ids( + extract_openai_model_ids(payload, provider_name="CLOUDFLARE") + ) + finally: + await maybe_await_aclose(response) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + return build_openai_chat_request_body( + request, + thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + policy=_REQUEST_POLICY, + postprocessors=(_apply_cloudflare_request_quirks,), + ) + + def _handle_extra_reasoning( + self, delta: Any, ledger: Any, *, thinking_enabled: bool + ) -> Iterator[str]: + """Map Cloudflare's ``reasoning`` delta field to Anthropic thinking.""" + reasoning = _cloudflare_reasoning(delta) + if not thinking_enabled or not reasoning: + return + yield from ledger.ensure_thinking_block() + yield ledger.emit_thinking_delta(reasoning) + + def _model_list_headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self._api_key}"} + + +def _apply_cloudflare_request_quirks( + body: dict[str, Any], _request: MessagesRequest, thinking_enabled: bool +) -> None: + """Attach Cloudflare Workers AI chat-template thinking control.""" + extra_body = body.setdefault("extra_body", {}) + if not isinstance(extra_body, dict): + return + chat_template_kwargs = extra_body.setdefault("chat_template_kwargs", {}) + if isinstance(chat_template_kwargs, dict): + chat_template_kwargs.setdefault("thinking", thinking_enabled) + + +def _cloudflare_reasoning(delta: Any) -> str | None: + reasoning = getattr(delta, "reasoning", None) + if isinstance(reasoning, str) and reasoning: + return reasoning + + model_extra = getattr(delta, "model_extra", None) + if isinstance(model_extra, Mapping): + reasoning = model_extra.get("reasoning") + if isinstance(reasoning, str) and reasoning: + return reasoning + + return None diff --git a/src/free_claude_code/providers/deepseek/__init__.py b/src/free_claude_code/providers/deepseek/__init__.py new file mode 100644 index 0000000..7a1e886 --- /dev/null +++ b/src/free_claude_code/providers/deepseek/__init__.py @@ -0,0 +1,5 @@ +"""DeepSeek provider exports.""" + +from .client import DeepSeekProvider + +__all__ = ["DeepSeekProvider"] diff --git a/src/free_claude_code/providers/deepseek/client.py b/src/free_claude_code/providers/deepseek/client.py new file mode 100644 index 0000000..dfaa211 --- /dev/null +++ b/src/free_claude_code/providers/deepseek/client.py @@ -0,0 +1,46 @@ +"""DeepSeek provider implementation (OpenAI-compatible Chat Completions).""" + +from typing import Any + +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, + usage_int, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +from .compat import build_deepseek_request_body + +_PROFILE = OpenAIChatProfile(OpenAIChatRequestPolicy(provider_name="DEEPSEEK")) + + +class DeepSeekProvider(OpenAIChatProvider): + """DeepSeek using ``https://api.deepseek.com`` Chat Completions.""" + + def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter): + super().__init__( + config, + profile=_PROFILE, + rate_limiter=rate_limiter, + ) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + return build_deepseek_request_body( + request, + thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + ) + + def _anthropic_usage_fields(self, usage_info: Any) -> dict[str, int]: + usage_fields: dict[str, int] = {} + cache_hit_tokens = usage_int(usage_info, "prompt_cache_hit_tokens") + if cache_hit_tokens is not None: + usage_fields["cache_read_input_tokens"] = cache_hit_tokens + cache_miss_tokens = usage_int(usage_info, "prompt_cache_miss_tokens") + if cache_miss_tokens is not None: + usage_fields["cache_creation_input_tokens"] = cache_miss_tokens + return usage_fields diff --git a/src/free_claude_code/providers/deepseek/compat.py b/src/free_claude_code/providers/deepseek/compat.py new file mode 100644 index 0000000..a6248a8 --- /dev/null +++ b/src/free_claude_code/providers/deepseek/compat.py @@ -0,0 +1,432 @@ +"""DeepSeek Anthropic-to-OpenAI chat request policy.""" + +from collections.abc import Mapping +from typing import Any + +from loguru import logger + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.core.anthropic import ( + dump_messages_request, + serialize_tool_result_content, +) +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.openai_chat import ( + OpenAIChatRequestPolicy, + build_openai_chat_request_body, +) + +_REQUEST_POLICY = OpenAIChatRequestPolicy( + provider_name="DEEPSEEK", + include_extra_body=True, +) + +_UNSUPPORTED_MESSAGE_BLOCK_TYPES = frozenset( + { + "image", + "document", + "server_tool_use", + "web_search_tool_result", + "web_fetch_tool_result", + } +) +_STRIPPABLE_MESSAGE_BLOCK_TYPES = frozenset({"image", "document"}) +_OMITTED_ATTACHMENT_TEXT = ( + "[attachment omitted: DeepSeek does not support image or document inputs]" +) +_OMITTED_ATTACHMENT_BLOCK = {"type": "text", "text": _OMITTED_ATTACHMENT_TEXT} + + +def build_deepseek_request_body( + request_data: MessagesRequest, *, thinking_enabled: bool +) -> dict: + """Build a DeepSeek Chat Completions body from an Anthropic request.""" + logger.debug( + "DEEPSEEK_REQUEST: chat build model={} msgs={}", + request_data.model, + len(request_data.messages), + ) + + data = dump_messages_request(request_data) + if "messages" in data: + data["messages"] = _strip_unsupported_attachment_blocks(data["messages"]) + _validate_deepseek_request_dict(data) + _downgrade_forced_tool_choice(data) + + has_tool_history = _has_tool_history(data) + has_replayable_tool_thinking = _has_replayable_tool_thinking(data) + unsafe_tool_followup = has_tool_history and not has_replayable_tool_thinking + effective_thinking_enabled = thinking_enabled and not unsafe_tool_followup + if thinking_enabled: + if unsafe_tool_followup: + logger.debug( + "DEEPSEEK_REQUEST: disabling thinking for tool follow-up without " + "replayable thinking model={} msgs={} tools={}", + data.get("model"), + len(data.get("messages", [])), + len(data.get("tools", [])), + ) + _remove_deepseek_thinking_hints(data) + elif has_tool_history: + logger.debug( + "DEEPSEEK_REQUEST: keeping thinking for tool follow-up with " + "replayable thinking model={} msgs={} tools={}", + data.get("model"), + len(data.get("messages", [])), + len(data.get("tools", [])), + ) + elif data.get("tools") or data.get("tool_choice"): + logger.debug( + "DEEPSEEK_REQUEST: keeping thinking for initial tool request " + "model={} msgs={} tools={}", + data.get("model"), + len(data.get("messages", [])), + len(data.get("tools", [])), + ) + + if "messages" in data: + data["messages"] = _normalize_tool_result_content( + sanitize_deepseek_messages_for_openai( + data["messages"], + thinking_enabled=effective_thinking_enabled, + ) + ) + + sanitized_request = MessagesRequest.model_validate(data) + body = build_openai_chat_request_body( + sanitized_request, + thinking_enabled=effective_thinking_enabled, + policy=_REQUEST_POLICY, + postprocessors=(_apply_deepseek_chat_extras,), + ) + if "max_tokens" not in body or body.get("max_tokens") is None: + body["max_tokens"] = ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + logger.debug( + "DEEPSEEK_REQUEST: build done model={} msgs={} tools={}", + body.get("model"), + len(body.get("messages", [])), + len(body.get("tools", [])), + ) + return body + + +def sanitize_deepseek_messages_for_openai( + messages: Any, *, thinking_enabled: bool +) -> Any: + """Filter assistant content before converting to DeepSeek Chat Completions.""" + if not isinstance(messages, list): + return messages + + sanitized: list[Any] = [] + for message in messages: + if not isinstance(message, dict): + sanitized.append(message) + continue + if message.get("role") != "assistant": + sanitized.append(message) + continue + content = message.get("content") + if not isinstance(content, list): + sanitized.append(message) + continue + + if not thinking_enabled: + filtered = [ + block + for block in content + if not ( + isinstance(block, dict) + and block.get("type") in ("thinking", "redacted_thinking") + ) + ] + else: + filtered = [ + block + for block in content + if not ( + isinstance(block, dict) and block.get("type") == "redacted_thinking" + ) + ] + new_msg = dict(message) + new_msg["content"] = filtered or "" + sanitized.append(new_msg) + return sanitized + + +def _strip_unsupported_attachment_blocks(messages: Any) -> Any: + if not isinstance(messages, list): + return messages + + stripped: list[Any] = [] + top_level_dropped: dict[str, int] = {} + nested_dropped: dict[str, int] = {} + placeholder_replacements = 0 + + for message in messages: + if not isinstance(message, dict): + stripped.append(message) + continue + content = message.get("content") + if not isinstance(content, list): + stripped.append(message) + continue + + new_content: list[Any] = [] + message_dropped_attachment = False + for block in content: + if isinstance(block, dict): + btype = block.get("type") + if btype in _STRIPPABLE_MESSAGE_BLOCK_TYPES: + top_level_dropped[btype] = top_level_dropped.get(btype, 0) + 1 + message_dropped_attachment = True + continue + if btype == "tool_result": + inner = block.get("content") + if isinstance(inner, list): + filtered_inner: list[Any] = [] + for sub in inner: + if ( + isinstance(sub, dict) + and sub.get("type") in _STRIPPABLE_MESSAGE_BLOCK_TYPES + ): + sub_type = sub["type"] + nested_dropped[sub_type] = ( + nested_dropped.get(sub_type, 0) + 1 + ) + continue + filtered_inner.append(sub) + if not filtered_inner: + filtered_inner = [_OMITTED_ATTACHMENT_BLOCK] + placeholder_replacements += 1 + new_block = dict(block) + new_block["content"] = filtered_inner + new_content.append(new_block) + continue + new_content.append(block) + if not new_content and message_dropped_attachment: + new_content = [_OMITTED_ATTACHMENT_BLOCK] + placeholder_replacements += 1 + new_msg = dict(message) + new_msg["content"] = new_content + stripped.append(new_msg) + + if top_level_dropped or nested_dropped: + logger.warning( + "DEEPSEEK_REQUEST: stripped unsupported attachment blocks " + "(top_level={} nested_in_tool_result={} placeholder_tool_results={}). " + "DeepSeek has no vision/document support; the model will not see this content.", + dict(top_level_dropped), + dict(nested_dropped), + placeholder_replacements, + ) + return stripped + + +def _is_server_listed_tool(tool: Mapping[str, Any]) -> bool: + name = (tool.get("name") or "").strip() + if name in ("web_search", "web_fetch"): + return True + typ = tool.get("type") + if isinstance(typ, str): + return typ.startswith("web_search") or typ.startswith("web_fetch") + return False + + +def _walk_block_list_for_unsupported(blocks: Any, *, where: str) -> None: + if not isinstance(blocks, list): + return + for block in blocks: + if not isinstance(block, dict): + continue + btype = block.get("type") + if btype in _UNSUPPORTED_MESSAGE_BLOCK_TYPES: + raise InvalidRequestError( + f"DeepSeek native does not support {btype!r} blocks ({where})." + ) + if btype == "tool_result" and "content" in block: + _walk_block_list_for_unsupported( + block["content"], where=f"{where} (tool_result content)" + ) + + +def _validate_deepseek_request_dict(data: dict[str, Any]) -> None: + mcp = data.get("mcp_servers") + if mcp: + raise InvalidRequestError("DeepSeek does not support mcp_servers on requests.") + + for tool in data.get("tools") or (): + if not isinstance(tool, dict): + continue + if _is_server_listed_tool(tool): + raise InvalidRequestError( + "DeepSeek does not support listed Anthropic server tools " + "(web_search / web_fetch). Remove them or use a different provider." + ) + + for i, message in enumerate(data.get("messages") or ()): + if not isinstance(message, dict): + continue + content = message.get("content") + if isinstance(content, list): + _walk_block_list_for_unsupported(content, where=f"messages[{i}].content") + + system = data.get("system") + if isinstance(system, list): + _walk_block_list_for_unsupported(system, where="system") + + +def _has_tool_history_blocks(message: Mapping[str, Any]) -> bool: + role = message.get("role") + content = message.get("content") + if not isinstance(content, list): + return False + + for block in content: + if not isinstance(block, dict): + continue + btype = block.get("type") + if role == "assistant" and btype == "tool_use": + return True + if role == "user" and btype == "tool_result": + return True + return False + + +def _has_replayable_thinking_before_tool_use(message: Mapping[str, Any]) -> bool: + if message.get("role") != "assistant": + return False + content = message.get("content") + if not isinstance(content, list): + return False + + has_thinking = isinstance(message.get("reasoning_content"), str) + for block in content: + if not isinstance(block, dict): + continue + btype = block.get("type") + if btype == "thinking" and isinstance(block.get("thinking"), str): + has_thinking = True + continue + if btype == "tool_use": + return has_thinking + return False + + +def _has_tool_history(data: dict[str, Any]) -> bool: + for message in data.get("messages") or (): + if isinstance(message, Mapping) and _has_tool_history_blocks(message): + return True + return False + + +def _has_replayable_tool_thinking(data: dict[str, Any]) -> bool: + for message in data.get("messages") or (): + if isinstance(message, Mapping) and _has_replayable_thinking_before_tool_use( + message + ): + return True + return False + + +def _remove_deepseek_thinking_hints(data: dict[str, Any]) -> None: + output_config = data.get("output_config") + if isinstance(output_config, dict) and "effort" in output_config: + cleaned_output_config = dict(output_config) + cleaned_output_config.pop("effort", None) + if cleaned_output_config: + data["output_config"] = cleaned_output_config + else: + data.pop("output_config", None) + + context_management = data.get("context_management") + if not isinstance(context_management, dict): + return + edits = context_management.get("edits") + if not isinstance(edits, list): + return + filtered_edits = [ + edit + for edit in edits + if not ( + isinstance(edit, dict) + and isinstance(edit.get("type"), str) + and edit["type"].startswith("clear_thinking_") + ) + ] + if len(filtered_edits) == len(edits): + return + cleaned_context_management = dict(context_management) + if filtered_edits: + cleaned_context_management["edits"] = filtered_edits + data["context_management"] = cleaned_context_management + else: + cleaned_context_management.pop("edits", None) + if cleaned_context_management: + data["context_management"] = cleaned_context_management + else: + data.pop("context_management", None) + + +def _normalize_tool_result_content(messages: Any) -> Any: + if not isinstance(messages, list): + return messages + + normalized: list[Any] = [] + for message in messages: + if not isinstance(message, dict): + normalized.append(message) + continue + + content = message.get("content") + if not isinstance(content, list): + normalized.append(message) + continue + + new_content: list[Any] = [] + for block in content: + if not isinstance(block, dict): + new_content.append(block) + continue + + if block.get("type") == "tool_result": + normalized_block = dict(block) + normalized_block["content"] = serialize_tool_result_content( + block.get("content") + ) + new_content.append(normalized_block) + else: + new_content.append(block) + + new_msg = dict(message) + new_msg["content"] = new_content + normalized.append(new_msg) + + return normalized + + +def _downgrade_forced_tool_choice(data: dict[str, Any]) -> None: + tool_choice = data.get("tool_choice") + if not isinstance(tool_choice, dict): + return + if tool_choice.get("type") != "tool" or not isinstance( + tool_choice.get("name"), str + ): + return + logger.debug( + "DEEPSEEK_REQUEST: downgrading forced tool_choice to auto for unsupported " + "native request shape tool={}", + tool_choice["name"], + ) + data["tool_choice"] = {"type": "auto"} + + +def _apply_deepseek_chat_extras( + body: dict[str, Any], _request_data: MessagesRequest, thinking_enabled: bool +) -> None: + if not thinking_enabled or body.get("model") == "deepseek-reasoner": + return + extra_body = body.setdefault("extra_body", {}) + if isinstance(extra_body, dict): + extra_body.setdefault("thinking", {"type": "enabled"}) diff --git a/src/free_claude_code/providers/failure_policy.py b/src/free_claude_code/providers/failure_policy.py new file mode 100644 index 0000000..f65fb5f --- /dev/null +++ b/src/free_claude_code/providers/failure_policy.py @@ -0,0 +1,393 @@ +"""Provider-owned SDK classification and retry qualification.""" + +import json +from collections.abc import Callable, Mapping +from contextlib import suppress +from dataclasses import replace +from typing import Any + +import httpx +import openai + +from free_claude_code.core.diagnostics import ( + extract_upstream_error_detail, + format_execution_failure_message, + safe_exception_message, +) +from free_claude_code.core.failures import ExecutionFailure, FailureKind + +MarkRateLimited = Callable[[float], None] +ProviderFailureOverride = Callable[[Exception], ExecutionFailure | None] + +_RATE_LIMIT_MARKERS = frozenset({"rate_limit", "rate limit", "too many requests"}) +_OVERLOAD_MARKERS = frozenset( + { + "resourceexhausted", + "resource exhausted", + "limit reached", + "overloaded", + "capacity", + } +) +_INTERNAL_ERROR_MARKERS = frozenset({"internal_server_error", "internal server error"}) +_AUTHENTICATION_MESSAGE = "Provider authentication failed. Check API key." +_RATE_LIMIT_MESSAGE = "Provider rate limit reached. Please retry shortly." +_INVALID_REQUEST_MESSAGE = "Invalid request sent to provider." +_OVERLOADED_MESSAGE = "Provider is currently overloaded. Please retry." + + +def classify_provider_failure( + exc: Exception, + *, + provider_name: str, + read_timeout_s: float | None, + request_id: str | None, + mark_rate_limited: MarkRateLimited, + provider_failure_override: ProviderFailureOverride | None = None, +) -> ExecutionFailure: + """Return one detailed canonical failure after provider retries are exhausted.""" + if isinstance(exc, ExecutionFailure): + failure = exc + message = failure.message + request_id_line = f"Request ID: {request_id}" if request_id else None + if request_id_line and request_id_line not in message: + message = f"{message}\n\n{request_id_line}" + return replace(failure, message=message) + + failure = ( + provider_failure_override(exc) + if provider_failure_override is not None + else None + ) + if failure is None: + failure = _classify_provider_failure( + exc, + read_timeout_s=read_timeout_s, + mark_rate_limited=mark_rate_limited, + ) + message = format_execution_failure_message( + failure, + extract_upstream_error_detail(exc), + upstream_name=provider_name, + request_id=request_id, + ) + return replace(failure, message=message) + + +def overloaded_provider_failure() -> ExecutionFailure: + """Return the canonical provider-overload meaning and stable wording.""" + return _failure(FailureKind.OVERLOADED, 529, _OVERLOADED_MESSAGE, True) + + +def retryable_transient_status(exc: BaseException) -> int | None: + """Infer a retryable HTTP-like status from one upstream exception.""" + if isinstance(exc, ExecutionFailure): + status = exc.status_code + return status if exc.retryable and _is_retryable_status(status) else None + if isinstance(exc, openai.RateLimitError): + return 429 + if isinstance(exc, httpx.HTTPStatusError): + status = exc.response.status_code + return status if _is_retryable_status(status) else None + + status = _status_from_exception(exc) + if _is_retryable_status(status): + return status + + body_status = _status_from_body(getattr(exc, "body", None)) + if _is_retryable_status(body_status): + return body_status + + text = transient_error_text(exc) + if _has_marker(text, _RATE_LIMIT_MARKERS): + return 429 + if _has_marker(text, _OVERLOAD_MARKERS): + return 503 + if _has_marker(text, _INTERNAL_ERROR_MARKERS): + return 500 + return None + + +def is_transient_overload_error(exc: BaseException) -> bool: + """Return whether an upstream exception reports overload or capacity pressure.""" + if isinstance(exc, ExecutionFailure): + return exc.kind == FailureKind.OVERLOADED + return _has_marker(transient_error_text(exc), _OVERLOAD_MARKERS) + + +def transient_error_text(exc: BaseException) -> str: + """Combine exception, body, and response text for provider classification.""" + parts = [str(exc)] + body = getattr(exc, "body", None) + if body is not None: + parts.append(_body_to_text(body)) + response = getattr(exc, "response", None) + if response is not None: + with suppress(Exception): + parts.append(response.text) + return " ".join(part for part in parts if part).lower() + + +def is_retryable_provider_error(exc: BaseException) -> bool: + """Return whether provider policy permits stream retry or recovery.""" + if isinstance(exc, ExecutionFailure): + return exc.retryable + if isinstance(exc, openai.AuthenticationError | openai.BadRequestError): + return False + if retryable_transient_status(exc) is not None: + return True + return isinstance( + exc, + ( + TimeoutError, + httpx.TimeoutException, + httpx.ConnectError, + httpx.ReadError, + httpx.WriteError, + httpx.RemoteProtocolError, + httpx.NetworkError, + openai.APITimeoutError, + openai.APIConnectionError, + ), + ) + + +def retryable_upstream_status(exc: BaseException) -> int | None: + """Return a status eligible for provider-opening backoff.""" + status = retryable_transient_status(exc) + return status if status is not None and _is_retryable_status(status) else None + + +def retryable_upstream_transport_error(exc: BaseException) -> bool: + """Return whether a pre-response transport failure can be retried.""" + if isinstance(exc, ExecutionFailure): + return exc.retryable and retryable_transient_status(exc) is None + if isinstance(exc, openai.AuthenticationError | openai.BadRequestError): + return False + return isinstance( + exc, + ( + TimeoutError, + httpx.TimeoutException, + httpx.ConnectError, + httpx.ReadError, + httpx.WriteError, + httpx.RemoteProtocolError, + httpx.NetworkError, + openai.APITimeoutError, + openai.APIConnectionError, + ), + ) + + +def provider_error_message( + exc: BaseException, + *, + read_timeout_s: float | None = None, +) -> str: + """Map raw provider exception types to stable customer-facing wording.""" + if isinstance(exc, ExecutionFailure): + return exc.message + if isinstance(exc, httpx.ReadTimeout): + if read_timeout_s is not None: + return f"Provider request timed out after {read_timeout_s:g}s." + return "Provider request timed out." + if isinstance(exc, httpx.ConnectTimeout | httpx.ConnectError): + return "Could not connect to provider." + if isinstance(exc, httpx.RemoteProtocolError): + return "Provider connection was interrupted before a response was received." + if isinstance(exc, TimeoutError): + if read_timeout_s is not None: + return f"Provider request timed out after {read_timeout_s:g}s." + return "Request timed out." + if isinstance(exc, openai.RateLimitError): + return _RATE_LIMIT_MESSAGE + if isinstance(exc, openai.AuthenticationError): + return _AUTHENTICATION_MESSAGE + if isinstance(exc, openai.BadRequestError): + return _INVALID_REQUEST_MESSAGE + return safe_exception_message(exc) + + +def _classify_provider_failure( + exc: Exception, + *, + read_timeout_s: float | None, + mark_rate_limited: MarkRateLimited, +) -> ExecutionFailure: + if isinstance(exc, ExecutionFailure): + if exc.kind == FailureKind.RATE_LIMIT: + mark_rate_limited(60) + return exc + + if isinstance(exc, openai.AuthenticationError): + return _failure(FailureKind.AUTHENTICATION, 401, _AUTHENTICATION_MESSAGE, False) + if isinstance(exc, openai.RateLimitError): + mark_rate_limited(60) + return _failure(FailureKind.RATE_LIMIT, 429, _RATE_LIMIT_MESSAGE, True) + if isinstance(exc, openai.BadRequestError): + return _failure( + FailureKind.INVALID_REQUEST, 400, _INVALID_REQUEST_MESSAGE, False + ) + if isinstance(exc, openai.APITimeoutError): + return _failure(FailureKind.TIMEOUT, 500, _stable_upstream(500), True) + if isinstance(exc, openai.APIConnectionError): + return _failure(FailureKind.UNAVAILABLE, 500, _stable_upstream(500), True) + if isinstance(exc, openai.InternalServerError): + status = retryable_transient_status(exc) or getattr(exc, "status_code", None) + if is_transient_overload_error(exc): + return overloaded_provider_failure() + if isinstance(status, int) and 500 <= status <= 599: + return _failure( + FailureKind.UPSTREAM, + status, + _stable_upstream(status), + True, + ) + return _failure(FailureKind.UPSTREAM, 500, _stable_upstream(500), True) + if isinstance(exc, openai.APIError): + status = retryable_transient_status(exc) + if status == 429: + mark_rate_limited(60) + return _failure(FailureKind.RATE_LIMIT, 429, _RATE_LIMIT_MESSAGE, True) + if is_transient_overload_error(exc): + return overloaded_provider_failure() + effective_status = status or getattr(exc, "status_code", None) + if not isinstance(effective_status, int): + effective_status = 500 + return _failure( + FailureKind.UPSTREAM, + effective_status, + _stable_upstream(effective_status), + is_retryable_provider_error(exc), + ) + + if isinstance(exc, httpx.HTTPStatusError): + status = exc.response.status_code + if status in (401, 403): + return _failure( + FailureKind.AUTHENTICATION, 401, _AUTHENTICATION_MESSAGE, False + ) + if status == 429: + mark_rate_limited(60) + return _failure(FailureKind.RATE_LIMIT, 429, _RATE_LIMIT_MESSAGE, True) + if status == 400: + return _failure( + FailureKind.INVALID_REQUEST, 400, _INVALID_REQUEST_MESSAGE, False + ) + if status in (502, 503, 504): + return overloaded_provider_failure() + return _failure( + FailureKind.UPSTREAM, + status, + _stable_upstream(status), + _is_retryable_status(status), + ) + + kind = FailureKind.UPSTREAM + if isinstance(exc, TimeoutError | httpx.TimeoutException): + kind = FailureKind.TIMEOUT + elif isinstance(exc, httpx.ConnectError | httpx.NetworkError): + kind = FailureKind.UNAVAILABLE + return _failure( + kind, + 502, + provider_error_message(exc, read_timeout_s=read_timeout_s), + is_retryable_provider_error(exc), + ) + + +def _failure( + kind: FailureKind, + status_code: int, + message: str, + retryable: bool, +) -> ExecutionFailure: + return ExecutionFailure( + kind=kind, + status_code=status_code, + message=message, + retryable=retryable, + ) + + +def _stable_upstream(status_code: int) -> str: + if status_code in (502, 503, 504): + return "Provider is temporarily unavailable. Please retry." + return "Provider API request failed." + + +def _status_from_exception(exc: BaseException) -> int | None: + status = getattr(exc, "status_code", None) + return status if isinstance(status, int) else None + + +def _status_from_body(body: Any) -> int | None: + for item in _body_candidates(body): + if not isinstance(item, Mapping): + continue + for key in ("status", "status_code", "code"): + status = _coerce_status(item.get(key)) + if status is not None: + return status + type_status = _status_from_type_fields(item) + if type_status is not None: + return type_status + return None + + +def _body_candidates(body: Any) -> tuple[Any, ...]: + if isinstance(body, str): + try: + return _body_candidates(json.loads(body)) + except ValueError: + return (body,) + if isinstance(body, bytes): + return _body_candidates(body.decode("utf-8", errors="replace")) + if isinstance(body, Mapping): + nested = body.get("error") + return (body, nested) if isinstance(nested, Mapping) else (body,) + return (body,) + + +def _coerce_status(value: Any) -> int | None: + if isinstance(value, int): + return value + if isinstance(value, str) and value.isdigit(): + return int(value) + return None + + +def _status_from_type_fields(item: Mapping[str, Any]) -> int | None: + values = [ + value.lower() + for key in ("type", "code") + if isinstance((value := item.get(key)), str) + ] + text = " ".join(values) + if _has_marker(text, _RATE_LIMIT_MARKERS): + return 429 + if _has_marker(text, _OVERLOAD_MARKERS): + return 503 + if _has_marker(text, _INTERNAL_ERROR_MARKERS): + return 500 + return None + + +def _body_to_text(body: Any) -> str: + if isinstance(body, bytes): + return body.decode("utf-8", errors="replace") + if isinstance(body, str): + return body + try: + return json.dumps(body, ensure_ascii=False, separators=(",", ":")) + except TypeError: + return str(body) + + +def _has_marker(text: str, markers: frozenset[str]) -> bool: + return any(marker in text for marker in markers) + + +def _is_retryable_status(status: int | None) -> bool: + return isinstance(status, int) and (status == 429 or 500 <= status <= 599) diff --git a/src/free_claude_code/providers/gemini/__init__.py b/src/free_claude_code/providers/gemini/__init__.py new file mode 100644 index 0000000..2e0bd84 --- /dev/null +++ b/src/free_claude_code/providers/gemini/__init__.py @@ -0,0 +1,5 @@ +"""Google AI Studio Gemini (OpenAI-compat) adapter.""" + +from .client import GeminiProvider + +__all__ = ["GeminiProvider"] diff --git a/src/free_claude_code/providers/gemini/client.py b/src/free_claude_code/providers/gemini/client.py new file mode 100644 index 0000000..b5874e9 --- /dev/null +++ b/src/free_claude_code/providers/gemini/client.py @@ -0,0 +1,62 @@ +"""Google AI Studio Gemini provider (OpenAI-compatible chat completions).""" + +from copy import deepcopy +from typing import Any + +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, + build_openai_chat_request_body, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +from .quirks import apply_gemini_request_quirks + +_MAX_TOOL_CALL_EXTRA_CONTENT_CACHE = 4096 +_REQUEST_POLICY = OpenAIChatRequestPolicy(provider_name="GEMINI") +_PROFILE = OpenAIChatProfile(_REQUEST_POLICY) + + +class GeminiProvider(OpenAIChatProvider): + """Gemini API using ``https://generativelanguage.googleapis.com/v1beta/openai/``.""" + + def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter): + super().__init__( + config, + profile=_PROFILE, + rate_limiter=rate_limiter, + ) + self._tool_call_extra_content_by_id: dict[str, dict[str, Any]] = {} + + def _record_tool_call_extra_content( + self, tool_call_id: str, extra_content: dict[str, Any] + ) -> None: + if ( + tool_call_id not in self._tool_call_extra_content_by_id + and len(self._tool_call_extra_content_by_id) + >= _MAX_TOOL_CALL_EXTRA_CONTENT_CACHE + ): + self._tool_call_extra_content_by_id.pop( + next(iter(self._tool_call_extra_content_by_id)) + ) + self._tool_call_extra_content_by_id[tool_call_id] = deepcopy(extra_content) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + return build_openai_chat_request_body( + request, + thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + policy=_REQUEST_POLICY, + postprocessors=( + lambda body, request_data, enabled: apply_gemini_request_quirks( + body, + request_data, + enabled, + tool_call_extra_content_by_id=self._tool_call_extra_content_by_id, + ), + ), + ) diff --git a/src/free_claude_code/providers/gemini/quirks.py b/src/free_claude_code/providers/gemini/quirks.py new file mode 100644 index 0000000..ca5c16b --- /dev/null +++ b/src/free_claude_code/providers/gemini/quirks.py @@ -0,0 +1,171 @@ +"""Gemini request-body quirks for the shared OpenAI-chat provider.""" + +from copy import deepcopy +from typing import Any, cast + +from free_claude_code.core.anthropic.models import MessagesRequest + +GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator" + + +def apply_gemini_request_quirks( + body: dict[str, Any], + request_data: MessagesRequest, + thinking_enabled: bool, + *, + tool_call_extra_content_by_id: dict[str, dict[str, Any]] | None = None, +) -> None: + """Apply Google-specific request extensions after common OpenAI conversion.""" + extra_body: dict[str, Any] = {} + request_extra = request_data.extra_body + if isinstance(request_extra, dict): + extra_body.update(deepcopy(request_extra)) + + if thinking_enabled: + _apply_thinking_config(extra_body) + else: + body["reasoning_effort"] = "none" + + if extra_body: + body["extra_body"] = extra_body + + _apply_gemini_tool_call_signatures( + body, + tool_call_extra_content_by_id=tool_call_extra_content_by_id, + ) + + +def _ensure_dict(container: dict[str, Any], key: str) -> dict[str, Any]: + value = container.get(key) + if isinstance(value, dict): + return cast(dict[str, Any], value) + nested: dict[str, Any] = {} + container[key] = nested + return nested + + +def _apply_thinking_config(extra_body: dict[str, Any]) -> None: + # OpenAI's SDK merges its ``extra_body`` argument into the request JSON. + # Google expects its extension fields under a literal JSON ``extra_body`` key. + literal_extra_body = _ensure_dict(extra_body, "extra_body") + google_section = _ensure_dict(literal_extra_body, "google") + thinking_cfg = _ensure_dict(google_section, "thinking_config") + thinking_cfg.setdefault("include_thoughts", True) + + +def _is_gemini_3_model(model: Any) -> bool: + return "gemini-3" in str(model).lower() + + +def _thought_signature_from_extra_content(extra_content: Any) -> str | None: + if not isinstance(extra_content, dict): + return None + google = extra_content.get("google") + if not isinstance(google, dict): + return None + signature = google.get("thought_signature") + return signature if isinstance(signature, str) and signature else None + + +def _tool_call_thought_signature(tool_call: dict[str, Any]) -> str | None: + return _thought_signature_from_extra_content(tool_call.get("extra_content")) + + +def _set_tool_call_thought_signature(tool_call: dict[str, Any], signature: str) -> None: + extra_content = tool_call.get("extra_content") + if not isinstance(extra_content, dict): + extra_content = {} + tool_call["extra_content"] = extra_content + google = extra_content.get("google") + if not isinstance(google, dict): + google = {} + extra_content["google"] = google + google["thought_signature"] = signature + + +def _message_has_standard_user_content(message: dict[str, Any]) -> bool: + if message.get("role") != "user": + return False + content = message.get("content") + if isinstance(content, str): + return bool(content.strip()) + if isinstance(content, list): + return any( + isinstance(part, dict) + and isinstance(part.get("text"), str) + and bool(part["text"].strip()) + for part in content + ) + return False + + +def _current_turn_start_index(messages: list[Any]) -> int: + for index in range(len(messages) - 1, -1, -1): + message = messages[index] + if isinstance(message, dict) and _message_has_standard_user_content(message): + return index + return -1 + + +def _apply_cached_tool_call_signatures( + messages: list[Any], tool_call_extra_content_by_id: dict[str, dict[str, Any]] +) -> None: + if not tool_call_extra_content_by_id: + return + for message in messages: + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + tool_calls = message.get("tool_calls") + if not isinstance(tool_calls, list): + continue + for tool_call in tool_calls: + if not isinstance(tool_call, dict) or _tool_call_thought_signature( + tool_call + ): + continue + tool_call_id = tool_call.get("id") + if tool_call_id is None: + continue + cached_extra_content = tool_call_extra_content_by_id.get(str(tool_call_id)) + if not cached_extra_content: + continue + cached_signature = _thought_signature_from_extra_content( + cached_extra_content + ) + if cached_signature: + tool_call["extra_content"] = deepcopy(cached_extra_content) + + +def _apply_gemini_3_missing_current_turn_signatures( + body: dict[str, Any], messages: list[Any] +) -> None: + if not _is_gemini_3_model(body.get("model")): + return + + start_index = _current_turn_start_index(messages) + for message in messages[start_index + 1 :]: + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + tool_calls = message.get("tool_calls") + if not isinstance(tool_calls, list) or not tool_calls: + continue + first_tool_call = tool_calls[0] + if not isinstance(first_tool_call, dict): + continue + if _tool_call_thought_signature(first_tool_call): + continue + _set_tool_call_thought_signature( + first_tool_call, GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR + ) + + +def _apply_gemini_tool_call_signatures( + body: dict[str, Any], + *, + tool_call_extra_content_by_id: dict[str, dict[str, Any]] | None, +) -> None: + messages = body.get("messages") + if not isinstance(messages, list): + return + _apply_cached_tool_call_signatures(messages, tool_call_extra_content_by_id or {}) + _apply_gemini_3_missing_current_turn_signatures(body, messages) diff --git a/src/free_claude_code/providers/github_models/__init__.py b/src/free_claude_code/providers/github_models/__init__.py new file mode 100644 index 0000000..d86c4d6 --- /dev/null +++ b/src/free_claude_code/providers/github_models/__init__.py @@ -0,0 +1,5 @@ +"""GitHub Models provider.""" + +from .client import GitHubModelsProvider + +__all__ = ["GitHubModelsProvider"] diff --git a/src/free_claude_code/providers/github_models/client.py b/src/free_claude_code/providers/github_models/client.py new file mode 100644 index 0000000..d7aab56 --- /dev/null +++ b/src/free_claude_code/providers/github_models/client.py @@ -0,0 +1,147 @@ +"""GitHub Models provider using OpenAI-compatible chat completions.""" + +from collections.abc import Mapping, Sequence +from typing import Any + +import httpx + +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.http import maybe_await_aclose +from free_claude_code.providers.model_listing import ( + ModelListResponseError, + model_infos_from_ids, +) +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, + build_openai_chat_request_body, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +GITHUB_MODELS_CATALOG_URL = "https://models.github.ai/catalog/models" +GITHUB_MODELS_API_VERSION = "2026-03-10" + +_REQUEST_POLICY = OpenAIChatRequestPolicy( + provider_name="GITHUB_MODELS", +) +_PROFILE = OpenAIChatProfile(_REQUEST_POLICY) +_REQUIRED_MODEL_CAPABILITIES = frozenset({"streaming", "tool-calling"}) + + +class GitHubModelsProvider(OpenAIChatProvider): + """GitHub Models OpenAI-compatible inference provider.""" + + def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter): + self._catalog_url = GITHUB_MODELS_CATALOG_URL + self._model_list_client = httpx.AsyncClient( + proxy=config.proxy or None, + timeout=httpx.Timeout( + config.http_read_timeout, + connect=config.http_connect_timeout, + read=config.http_read_timeout, + write=config.http_write_timeout, + ), + ) + super().__init__( + config, + profile=_PROFILE, + rate_limiter=rate_limiter, + default_headers=_github_models_default_headers(), + ) + + async def cleanup(self) -> None: + """Release provider client resources.""" + await super().cleanup() + await self._model_list_client.aclose() + + async def list_model_ids(self) -> frozenset[str]: + """Return GitHub Models ids that support FCC's streaming tool workflow.""" + return frozenset(info.model_id for info in await self.list_model_infos()) + + async def list_model_infos(self) -> frozenset[ProviderModelInfo]: + """Return stream/tool-capable GitHub Models catalog ids.""" + response = await self._model_list_client.get( + self._catalog_url, + headers=self._model_list_headers(), + ) + try: + response.raise_for_status() + try: + payload = response.json() + except ValueError as exc: + raise ModelListResponseError( + "GITHUB_MODELS model-list response is malformed: invalid JSON" + ) from exc + return model_infos_from_ids( + _extract_supported_github_model_ids(payload), + ) + finally: + await maybe_await_aclose(response) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + return build_openai_chat_request_body( + request, + thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + policy=_REQUEST_POLICY, + ) + + def _model_list_headers(self) -> dict[str, str]: + return _github_models_api_headers(self._api_key) + + +def _github_models_default_headers() -> dict[str, str]: + return { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": GITHUB_MODELS_API_VERSION, + } + + +def _github_models_api_headers(api_key: str) -> dict[str, str]: + return { + **_github_models_default_headers(), + "Authorization": f"Bearer {api_key}", + } + + +def _extract_supported_github_model_ids(payload: Any) -> frozenset[str]: + """Extract stream/tool-capable model ids from GitHub's catalog array.""" + if not _is_sequence(payload): + raise ModelListResponseError( + "GITHUB_MODELS model-list response is malformed: expected top-level array" + ) + + model_ids: set[str] = set() + for item in payload: + if not isinstance(item, Mapping): + raise ModelListResponseError( + "GITHUB_MODELS model-list response is malformed: expected every item to be an object" + ) + model_id = item.get("id") + if not isinstance(model_id, str) or not model_id.strip(): + raise ModelListResponseError( + "GITHUB_MODELS model-list response is malformed: expected every item to include id" + ) + capabilities = item.get("capabilities") + if not _supports_streaming_tools(capabilities): + continue + model_ids.add(model_id) + + return frozenset(model_ids) + + +def _supports_streaming_tools(capabilities: Any) -> bool: + if not _is_sequence(capabilities): + return False + capability_names = {item for item in capabilities if isinstance(item, str)} + return capability_names >= _REQUIRED_MODEL_CAPABILITIES + + +def _is_sequence(value: Any) -> bool: + return isinstance(value, Sequence) and not isinstance( + value, str | bytes | bytearray + ) diff --git a/src/free_claude_code/providers/http.py b/src/free_claude_code/providers/http.py new file mode 100644 index 0000000..1a83abb --- /dev/null +++ b/src/free_claude_code/providers/http.py @@ -0,0 +1,51 @@ +"""Shared HTTP lifecycle helpers for upstream provider clients.""" + +import inspect +from typing import Any + +from loguru import logger + +from free_claude_code.core.trace import trace_event + + +async def maybe_await_aclose(response: Any) -> None: + """Call ``aclose`` on httpx-like responses; ignore sync test doubles.""" + close = getattr(response, "aclose", None) + if not callable(close): + return + result = close() + if inspect.isawaitable(result): + await result + + +async def close_provider_stream( + stream: Any, + *, + active_error: BaseException | None, + provider_name: str, + request_id: str | None, +) -> None: + """Close one stream without letting cleanup change its established outcome.""" + try: + await maybe_await_aclose(stream) + except Exception as close_error: + active_error_type = ( + type(active_error).__name__ if active_error is not None else None + ) + trace_event( + stage="provider", + event="provider.stream.close_failed", + source="provider", + provider=provider_name, + request_id=request_id, + close_exc_type=type(close_error).__name__, + preserved_exc_type=active_error_type, + ) + logger.warning( + "{}_STREAM_CLOSE_FAILED request_id={} close_exc_type={} " + "preserved_exc_type={}", + provider_name, + request_id, + type(close_error).__name__, + active_error_type, + ) diff --git a/src/free_claude_code/providers/lmstudio/__init__.py b/src/free_claude_code/providers/lmstudio/__init__.py new file mode 100644 index 0000000..2674a9c --- /dev/null +++ b/src/free_claude_code/providers/lmstudio/__init__.py @@ -0,0 +1,5 @@ +"""LM Studio provider - OpenAI-compatible chat completions API.""" + +from .client import LMStudioProvider + +__all__ = ["LMStudioProvider"] diff --git a/src/free_claude_code/providers/lmstudio/client.py b/src/free_claude_code/providers/lmstudio/client.py new file mode 100644 index 0000000..84f3a2f --- /dev/null +++ b/src/free_claude_code/providers/lmstudio/client.py @@ -0,0 +1,127 @@ +"""LM Studio provider implementation (OpenAI-compatible chat completions). + +Switched from LM Studio's native Anthropic Messages endpoint (2026-07-04): +the newer ``/v1/messages`` path renders Claude Code conversations through the +model's jinja chat template with strict role-alternation rules and a fragile +``[TOOL_CALLS]`` parser — observed leaking control tokens into tool names +(``[TOOL_CALLS]Read``) and dumping whole tool calls into text +(``Read[ARGS]{...}``), which ends agent runs silently. The OpenAI +``/v1/chat/completions`` path is LM Studio's mature parsing route, and fcc's +OpenAI provider layers its own tool-call assembly, think-tag parsing, and +heuristic recovery on top. +""" + +import time + +import httpx +from loguru import logger + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.core.anthropic import ( + ReasoningReplayMode, + build_base_request_body, + get_token_count, +) +from free_claude_code.core.anthropic.conversion import OpenAIConversionError +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +_PROFILE = OpenAIChatProfile(OpenAIChatRequestPolicy(provider_name="LMSTUDIO")) + + +class LMStudioProvider(OpenAIChatProvider): + """LM Studio via its OpenAI-compatible chat completions endpoint.""" + + # LM Studio truncates the stream silently (no terminal event) when the + # prompt exceeds the loaded context. Refuse clearly over-budget prompts + # up front with the same "prompt is too long" invalid_request_error the + # real Anthropic API uses, so Claude Code can compact/retry instead of + # dying mid-stream. + _CONTEXT_CACHE_TTL_S = 30.0 + + def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter): + super().__init__( + config, + profile=_PROFILE, + rate_limiter=rate_limiter, + ) + self._loaded_context_cache: tuple[float, int | None] = (0.0, None) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + """Build an OpenAI chat body from the Anthropic request. + + Prior-turn thinking is never replayed: Mistral-family templates have + no assistant reasoning field, and replaying ```` text inflates + the local context for no benefit. New-response thinking still streams + back via ``reasoning_content``/```` parsing in the provider. + """ + try: + return build_base_request_body( + request, + reasoning_replay=ReasoningReplayMode.DISABLED, + ) + except OpenAIConversionError as exc: + raise InvalidRequestError(str(exc)) from exc + + def preflight_stream( + self, request: MessagesRequest, *, thinking_enabled: bool | None = None + ) -> None: + super().preflight_stream(request, thinking_enabled=thinking_enabled) + self._preflight_context_budget(request) + + def _preflight_context_budget(self, request: MessagesRequest) -> None: + loaded_context = self._loaded_context_length() + if loaded_context is None: + return + estimate = get_token_count( + request.messages, + request.system, + request.tools, + ) + # The estimate is cl100k-based and undercounts local tokenizers + # (observed ~8% low vs devstral); a request above 90% of the loaded + # context is already past where client-side compaction should have + # fired, and letting it through risks a silent LM Studio truncation. + budget = int(loaded_context * 0.9) + if estimate > budget: + raise InvalidRequestError( + f"prompt is too long: {estimate} tokens > {budget} " + f"maximum (90% of loaded LM Studio context {loaded_context})" + ) + + def _loaded_context_length(self) -> int | None: + """Best-effort loaded context length from LM Studio's REST API, cached.""" + cached_at, cached_value = self._loaded_context_cache + if time.monotonic() - cached_at < self._CONTEXT_CACHE_TTL_S: + return cached_value + + value: int | None = None + try: + root = self._base_url + root = root[: -len("/v1")] if root.endswith("/v1") else root + response = httpx.get(f"{root}/api/v0/models", timeout=2.0) + response.raise_for_status() + loaded = [ + model.get("loaded_context_length") + for model in response.json().get("data", []) + if model.get("state") == "loaded" + and isinstance(model.get("loaded_context_length"), int) + ] + # ponytail: single-model setups in practice; with several loaded + # models the most generous ceiling still makes a valid backstop. + value = max(loaded) if loaded else None + except Exception as error: # backstop only — never block the request + logger.debug( + "LMSTUDIO context preflight unavailable: {}", type(error).__name__ + ) + value = None + self._loaded_context_cache = (time.monotonic(), value) + return value diff --git a/src/free_claude_code/providers/mistral/__init__.py b/src/free_claude_code/providers/mistral/__init__.py new file mode 100644 index 0000000..050ba32 --- /dev/null +++ b/src/free_claude_code/providers/mistral/__init__.py @@ -0,0 +1,5 @@ +"""Mistral La Plateforme provider exports.""" + +from .client import MistralProvider + +__all__ = ["MistralProvider"] diff --git a/src/free_claude_code/providers/mistral/client.py b/src/free_claude_code/providers/mistral/client.py new file mode 100644 index 0000000..9040110 --- /dev/null +++ b/src/free_claude_code/providers/mistral/client.py @@ -0,0 +1,68 @@ +"""Mistral La Plateforme provider implementation (OpenAI-compatible chat completions).""" + +from typing import Any + +from loguru import logger + +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, + build_openai_chat_request_body, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +from .reasoning import ( + apply_mistral_reasoning_request_shape, + clone_body_without_mistral_reasoning, + is_mistral_reasoning_rejection, + normalize_mistral_stream, +) + +_REQUEST_POLICY = OpenAIChatRequestPolicy(provider_name="MISTRAL") +_PROFILE = OpenAIChatProfile(_REQUEST_POLICY) + + +class MistralProvider(OpenAIChatProvider): + """Mistral API using ``https://api.mistral.ai/v1/chat/completions``.""" + + def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter): + super().__init__( + config, + profile=_PROFILE, + rate_limiter=rate_limiter, + ) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + effective_thinking_enabled = self._is_thinking_enabled( + request, thinking_enabled + ) + body = build_openai_chat_request_body( + request, + thinking_enabled=effective_thinking_enabled, + policy=_REQUEST_POLICY, + ) + apply_mistral_reasoning_request_shape( + body, thinking_enabled=effective_thinking_enabled + ) + return body + + def _get_retry_request_body(self, error: Exception, body: dict) -> dict | None: + """Retry once without Mistral reasoning fields when a model rejects them.""" + if not is_mistral_reasoning_rejection(error): + return None + retry_body = clone_body_without_mistral_reasoning(body) + if retry_body is None: + return None + logger.warning( + "MISTRAL_STREAM: retrying without reasoning after upstream rejection" + ) + return retry_body + + async def _create_stream(self, body: dict) -> tuple[Any, dict]: + stream, final_body = await super()._create_stream(body) + return normalize_mistral_stream(stream), final_body diff --git a/src/free_claude_code/providers/mistral/reasoning.py b/src/free_claude_code/providers/mistral/reasoning.py new file mode 100644 index 0000000..9b62d1c --- /dev/null +++ b/src/free_claude_code/providers/mistral/reasoning.py @@ -0,0 +1,408 @@ +"""Mistral La Plateforme reasoning compatibility helpers.""" + +import json +from collections.abc import AsyncIterator, Mapping, Sequence +from copy import deepcopy +from types import SimpleNamespace +from typing import Any + +import openai + +from free_claude_code.providers.http import maybe_await_aclose + +MISTRAL_REASONING_EFFORT = "high" + +_REASONING_FIELD_NAMES = frozenset( + { + "reasoning_content", + "reasoning_effort", + "thinkchunk", + } +) +_REJECTION_WORDS = ("unsupported", "unknown", "invalid", "forbidden", "extra") + + +def apply_mistral_reasoning_request_shape( + body: dict[str, Any], *, thinking_enabled: bool +) -> None: + """Apply Mistral's native reasoning request shape in-place.""" + if thinking_enabled: + body["reasoning_effort"] = MISTRAL_REASONING_EFFORT + else: + body.pop("reasoning_effort", None) + + messages = body.get("messages") + if not isinstance(messages, list): + return + + for message in messages: + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + reasoning = _clean_text(message.pop("reasoning_content", None)) + if thinking_enabled and reasoning: + message["content"] = _content_with_prepended_thinking( + message.get("content"), reasoning + ) + elif not thinking_enabled: + message["content"] = _content_without_thinking(message.get("content")) + + +def clone_body_without_mistral_reasoning( + body: dict[str, Any], +) -> dict[str, Any] | None: + """Return a body clone with Mistral reasoning fields removed.""" + cloned = deepcopy(body) + removed = cloned.pop("reasoning_effort", None) is not None + + messages = cloned.get("messages") + if isinstance(messages, list): + for message in messages: + if not isinstance(message, dict): + continue + if message.pop("reasoning_content", None) is not None: + removed = True + content = message.get("content") + stripped_content, content_removed = _strip_mistral_thinking_content(content) + if content_removed: + message["content"] = stripped_content + removed = True + + if not removed: + return None + return cloned + + +def is_mistral_reasoning_rejection(error: Exception) -> bool: + """Return whether an upstream error rejects Mistral reasoning request fields.""" + status_code = getattr(error, "status_code", None) + if not isinstance(error, openai.BadRequestError) and status_code not in (400, 422): + return False + + error_body = getattr(error, "body", None) + if _contains_reasoning_rejection(error_body): + return True + + error_text_parts = [str(error)] + response = getattr(error, "response", None) + if response is not None: + try: + response_text = response.text + except Exception: + response_text = None + if response_text: + if _contains_reasoning_rejection(_json_payload(response_text)): + return True + error_text_parts.append(response_text) + + if error_body is not None: + error_text_parts.append(json.dumps(error_body, default=str)) + + return _reasoning_rejection_text(" ".join(error_text_parts)) + + +def normalize_mistral_stream(stream: Any) -> AsyncIterator[Any]: + """Yield OpenAI-chat chunks with Mistral native thinking chunks normalized.""" + + async def _iter() -> AsyncIterator[Any]: + try: + async for chunk in stream: + yield normalize_mistral_chunk(chunk) + finally: + await maybe_await_aclose(stream) + + return _iter() + + +def normalize_mistral_chunk(chunk: Any) -> Any: + """Normalize one Mistral OpenAI-compatible stream chunk.""" + choices = getattr(chunk, "choices", None) + if not choices: + return chunk + + normalized_choices: list[Any] = [] + changed = False + for choice in choices: + delta = getattr(choice, "delta", None) + normalized_delta = _normalize_mistral_delta(delta) + if normalized_delta is delta: + normalized_choices.append(choice) + continue + changed = True + normalized_choices.append( + SimpleNamespace( + delta=normalized_delta, + finish_reason=getattr(choice, "finish_reason", None), + ) + ) + + if not changed: + return chunk + return SimpleNamespace( + choices=normalized_choices, + usage=getattr(chunk, "usage", None), + ) + + +def _normalize_mistral_delta(delta: Any) -> Any: + if delta is None: + return delta + + content = _field(delta, "content") + text, reasoning, changed = _split_mistral_content(content) + native_reasoning = _extract_native_reasoning(delta) + if native_reasoning: + reasoning = "\n".join(part for part in (reasoning, native_reasoning) if part) + if isinstance(content, str): + text = content + changed = True + + existing_reasoning = _field(delta, "reasoning_content") + if isinstance(existing_reasoning, str) and existing_reasoning: + reasoning = "\n".join(part for part in (existing_reasoning, reasoning) if part) + + if not changed: + return delta + + return SimpleNamespace( + content=text, + reasoning_content=reasoning or None, + tool_calls=_field(delta, "tool_calls"), + ) + + +def _content_with_prepended_thinking( + content: Any, reasoning: str +) -> list[dict[str, Any]]: + chunks = [_thinking_chunk(reasoning)] + chunks.extend(_content_to_mistral_text_chunks(content)) + return chunks + + +def _content_without_thinking(content: Any) -> Any: + stripped, _ = _strip_mistral_thinking_content(content) + return stripped + + +def _strip_mistral_thinking_content(content: Any) -> tuple[Any, bool]: + if not _is_sequence(content): + return content, False + + text_parts: list[str] = [] + kept_chunks: list[Any] = [] + removed = False + can_collapse_to_text = True + + for chunk in content: + chunk_type = _chunk_type(chunk) + if _is_thinking_chunk_type(chunk_type): + removed = True + continue + if chunk_type == "text": + text = _chunk_text(chunk) + if text: + text_parts.append(text) + continue + kept_chunks.append(chunk) + can_collapse_to_text = False + + if can_collapse_to_text: + return "".join(text_parts), removed + return kept_chunks, removed + + +def _content_to_mistral_text_chunks(content: Any) -> list[dict[str, Any]]: + if isinstance(content, str): + return [_text_chunk(content)] if content else [] + if not _is_sequence(content): + text = _clean_text(content) + return [_text_chunk(text)] if text else [] + + chunks: list[dict[str, Any]] = [] + for chunk in content: + chunk_type = _chunk_type(chunk) + if _is_thinking_chunk_type(chunk_type): + continue + text = _chunk_text(chunk) + if text: + chunks.append(_text_chunk(text)) + return chunks + + +def _split_mistral_content(content: Any) -> tuple[str | None, str | None, bool]: + if not _is_sequence(content): + return None, None, False + + text_parts: list[str] = [] + reasoning_parts: list[str] = [] + for chunk in content: + chunk_type = _chunk_type(chunk) + if _is_thinking_chunk_type(chunk_type): + reasoning = _chunk_reasoning(chunk) + if reasoning: + reasoning_parts.append(reasoning) + continue + text = _chunk_text(chunk) + if text: + text_parts.append(text) + + return ( + "".join(text_parts) or None, + "".join(reasoning_parts) or None, + bool(text_parts or reasoning_parts), + ) + + +def _extract_native_reasoning(delta: Any) -> str | None: + for name in ("thinking", "reasoning"): + value = _field(delta, name) + text = _chunk_reasoning(value) + if text: + return text + return None + + +def _contains_reasoning_rejection(value: Any) -> bool: + if value is None: + return False + if isinstance(value, Mapping): + for key, item in value.items(): + key_text = str(key).lower() + if key_text in _REASONING_FIELD_NAMES: + return True + if key_text in {"loc", "param"} and _is_reasoning_field_path(item): + return True + if _contains_reasoning_rejection(item): + return True + return False + if _is_sequence(value): + if _is_reasoning_field_path(value): + return True + return any(_contains_reasoning_rejection(item) for item in value) + if isinstance(value, str): + return _reasoning_rejection_text(value) + return False + + +def _is_reasoning_field_path(value: Any) -> bool: + if not _is_sequence(value): + return False + parts = [str(part).lower() for part in value] + if any(part in _REASONING_FIELD_NAMES for part in parts): + return True + return "thinking" in parts and bool( + {"body", "messages", "assistant", "content"} & set(parts) + ) + + +def _reasoning_rejection_text(value: str) -> bool: + lowered = value.lower() + if "reasoning input" in lowered and any( + phrase in lowered + for phrase in ( + "not enabled", + "not supported", + "unsupported", + "disabled", + ) + ): + return True + if any(field in lowered for field in _REASONING_FIELD_NAMES): + return True + if "thinking" not in lowered or not any( + word in lowered for word in _REJECTION_WORDS + ): + return False + return any( + marker in lowered + for marker in ( + "body.messages", + "assistant.thinking", + "messages.assistant", + "content.thinking", + "field: thinking", + "field 'thinking'", + 'field "thinking"', + "property: thinking", + "property 'thinking'", + 'property "thinking"', + "parameter: thinking", + "parameter 'thinking'", + 'parameter "thinking"', + "param: thinking", + "param 'thinking'", + 'param "thinking"', + ) + ) + + +def _json_payload(value: str) -> Any: + try: + return json.loads(value) + except json.JSONDecodeError: + return None + + +def _thinking_chunk(reasoning: str) -> dict[str, Any]: + return { + "type": "thinking", + "thinking": [_text_chunk(reasoning)], + } + + +def _text_chunk(text: str) -> dict[str, str]: + return {"type": "text", "text": text} + + +def _chunk_reasoning(chunk: Any) -> str | None: + if isinstance(chunk, str): + return chunk or None + thinking = _field(chunk, "thinking") + if thinking is not None: + text = _chunk_text(thinking) + if text: + return text + text = _chunk_text(chunk) + return text or None + + +def _chunk_text(chunk: Any) -> str: + if isinstance(chunk, str): + return chunk + if _is_sequence(chunk): + return "".join(_chunk_text(part) for part in chunk) + text = _field(chunk, "text") + if isinstance(text, str): + return text + content = _field(chunk, "content") + if isinstance(content, str): + return content + return "" + + +def _chunk_type(chunk: Any) -> str | None: + value = _field(chunk, "type") + if isinstance(value, str): + return value + return None + + +def _is_thinking_chunk_type(chunk_type: str | None) -> bool: + return chunk_type in {"thinking", "reasoning"} + + +def _clean_text(value: Any) -> str | None: + if isinstance(value, str): + return value if value else None + return None + + +def _field(item: Any, name: str) -> Any: + if isinstance(item, Mapping): + return item.get(name) + return getattr(item, name, None) + + +def _is_sequence(value: Any) -> bool: + return isinstance(value, Sequence) and not isinstance( + value, str | bytes | bytearray + ) diff --git a/src/free_claude_code/providers/model_listing.py b/src/free_claude_code/providers/model_listing.py new file mode 100644 index 0000000..3d0f6f1 --- /dev/null +++ b/src/free_claude_code/providers/model_listing.py @@ -0,0 +1,107 @@ +"""Provider model-list response parsing helpers.""" + +from collections.abc import Iterable, Mapping, Sequence +from typing import Any + +from free_claude_code.application.model_metadata import ( + ProviderModelInfo as _ProviderModelInfo, +) + + +class ModelListResponseError(ValueError): + """A provider model-list response cannot be parsed safely.""" + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +def model_infos_from_ids( + model_ids: Iterable[str], *, supports_thinking: bool | None = None +) -> frozenset[_ProviderModelInfo]: + """Build unknown-capability model metadata from plain provider model ids.""" + return frozenset( + _ProviderModelInfo(model_id=model_id, supports_thinking=supports_thinking) + for model_id in model_ids + if model_id.strip() + ) + + +def extract_openai_model_ids(payload: Any, *, provider_name: str) -> frozenset[str]: + """Extract model ids from an OpenAI-compatible ``/models`` response.""" + data = _field(payload, "data") + if not _is_sequence(data): + raise _malformed(provider_name, "expected top-level data array") + + model_ids: set[str] = set() + for item in data: + model_id = _field(item, "id") + if not isinstance(model_id, str) or not model_id.strip(): + raise _malformed(provider_name, "expected every data item to include id") + model_ids.add(model_id) + + if not model_ids: + raise _malformed(provider_name, "response did not include any model ids") + return frozenset(model_ids) + + +def extract_openrouter_tool_model_ids( + payload: Any, *, provider_name: str +) -> frozenset[str]: + """Extract OpenRouter model ids that advertise tool-use support.""" + return frozenset( + info.model_id + for info in extract_openrouter_tool_model_infos( + payload, provider_name=provider_name + ) + ) + + +def extract_openrouter_tool_model_infos( + payload: Any, *, provider_name: str +) -> frozenset[_ProviderModelInfo]: + """Extract OpenRouter tool-capable model ids with thinking capability metadata.""" + data = _field(payload, "data") + if not _is_sequence(data): + raise _malformed(provider_name, "expected top-level data array") + + model_infos: set[_ProviderModelInfo] = set() + for item in data: + model_id = _field(item, "id") + if not isinstance(model_id, str) or not model_id.strip(): + raise _malformed(provider_name, "expected every data item to include id") + + supported_parameters = _field(item, "supported_parameters") + if not _is_sequence(supported_parameters): + continue + supported_parameter_names = { + param for param in supported_parameters if isinstance(param, str) + } + if supported_parameter_names.isdisjoint({"tools", "tool_choice"}): + continue + model_infos.add( + _ProviderModelInfo( + model_id=model_id, + supports_thinking="reasoning" in supported_parameter_names, + ) + ) + + return frozenset(model_infos) + + +def _field(item: Any, name: str) -> Any: + if isinstance(item, Mapping): + return item.get(name) + return getattr(item, name, None) + + +def _is_sequence(value: Any) -> bool: + return isinstance(value, Sequence) and not isinstance( + value, str | bytes | bytearray + ) + + +def _malformed(provider_name: str, reason: str) -> ModelListResponseError: + return ModelListResponseError( + f"{provider_name} model-list response is malformed: {reason}" + ) diff --git a/src/free_claude_code/providers/nvidia_nim/__init__.py b/src/free_claude_code/providers/nvidia_nim/__init__.py new file mode 100644 index 0000000..ee73b06 --- /dev/null +++ b/src/free_claude_code/providers/nvidia_nim/__init__.py @@ -0,0 +1,5 @@ +"""NVIDIA NIM provider package.""" + +from .client import NvidiaNimProvider + +__all__ = ["NvidiaNimProvider"] diff --git a/src/free_claude_code/providers/nvidia_nim/client.py b/src/free_claude_code/providers/nvidia_nim/client.py new file mode 100644 index 0000000..231f9ff --- /dev/null +++ b/src/free_claude_code/providers/nvidia_nim/client.py @@ -0,0 +1,147 @@ +"""NVIDIA NIM provider implementation.""" + +import json +from collections.abc import Mapping +from typing import Any + +import openai +from loguru import logger + +from free_claude_code.config.nim import NimSettings +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.failure_policy import ( + overloaded_provider_failure, +) +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +from .request_options import build_nim_request_body +from .retry import ( + clone_body_without_chat_template, + clone_body_without_reasoning_budget, + clone_body_without_reasoning_content, +) +from .tool_schema import ( + body_without_nim_tool_argument_aliases, + nim_tool_argument_aliases_from_body, +) + +_DEGRADED_FUNCTION_STATE = "degraded function cannot be invoked" +_PROFILE = OpenAIChatProfile(OpenAIChatRequestPolicy(provider_name="NIM")) + + +class NvidiaNimProvider(OpenAIChatProvider): + """NVIDIA NIM provider using official OpenAI client.""" + + def __init__( + self, + config: ProviderConfig, + *, + nim_settings: NimSettings, + rate_limiter: ProviderRateLimiter, + ): + super().__init__( + config, + profile=_PROFILE, + rate_limiter=rate_limiter, + ) + self._nim_settings = nim_settings + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + """Internal helper for tests and shared building.""" + return build_nim_request_body( + request, + self._nim_settings, + thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + ) + + def _prepare_create_body(self, body: dict[str, Any]) -> dict[str, Any]: + """Strip private request metadata before calling NVIDIA NIM.""" + return body_without_nim_tool_argument_aliases(body) + + def _tool_argument_aliases(self, body: dict[str, Any]) -> dict[str, dict[str, str]]: + """Return NIM tool argument aliases captured while building this request.""" + return nim_tool_argument_aliases_from_body(body) + + def _get_retry_request_body(self, error: Exception, body: dict) -> dict | None: + """Retry once with a downgraded body when NIM rejects a known field.""" + status_code = getattr(error, "status_code", None) + bad_request_like = isinstance(error, openai.BadRequestError) or ( + status_code == 400 + ) + + error_text = str(error) + error_body = getattr(error, "body", None) + if error_body is not None: + error_text = f"{error_text} {json.dumps(error_body, default=str)}" + error_text = error_text.lower() + + if _is_reasoning_budget_rejection(error_text) and ( + bad_request_like or status_code == 500 + ): + retry_body = clone_body_without_reasoning_budget(body) + if retry_body is None: + return None + logger.warning( + "NIM_STREAM: retrying without reasoning budget after upstream rejection" + ) + return retry_body + + if not bad_request_like: + return None + + if "chat_template" in error_text: + retry_body = clone_body_without_chat_template(body) + if retry_body is None: + return None + logger.warning("NIM_STREAM: retrying without chat_template after 400 error") + return retry_body + + if "reasoning_content" in error_text: + retry_body = clone_body_without_reasoning_content(body) + if retry_body is None: + return None + logger.warning( + "NIM_STREAM: retrying without reasoning_content after 400 error" + ) + return retry_body + + return None + + def _provider_failure_override(self, error: Exception) -> ExecutionFailure | None: + """Map NVIDIA Cloud Function deployment failure onto canonical overload.""" + if not isinstance(error, openai.BadRequestError): + return None + if getattr(error, "status_code", None) != 400: + return None + body = getattr(error, "body", None) + if not isinstance(body, Mapping): + return None + detail = body.get("detail") + if not isinstance(detail, str): + return None + function_ref, separator, state = detail.lower().partition(": ") + function_id = function_ref.removeprefix("function id ").strip(" '\"") + if ( + not separator + or not function_ref.startswith("function id ") + or not function_id + or state.strip() != _DEGRADED_FUNCTION_STATE + ): + return None + return overloaded_provider_failure() + + +def _is_reasoning_budget_rejection(error_text: str) -> bool: + """Return whether NIM rejected optional thinking budget control.""" + if "reasoning_budget" in error_text: + return True + return "thinking_token_budget" in error_text and "reasoning_config" in error_text diff --git a/src/free_claude_code/providers/nvidia_nim/request_options.py b/src/free_claude_code/providers/nvidia_nim/request_options.py new file mode 100644 index 0000000..7c88a1a --- /dev/null +++ b/src/free_claude_code/providers/nvidia_nim/request_options.py @@ -0,0 +1,108 @@ +"""NVIDIA NIM request option injection.""" + +from typing import Any + +from free_claude_code.config.nim import NimSettings +from free_claude_code.core.anthropic import set_if_not_none +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.openai_chat import ( + OpenAIChatRequestPolicy, + build_openai_chat_request_body, +) + +from .tool_schema import sanitize_nim_tool_schemas + +_REQUEST_POLICY = OpenAIChatRequestPolicy(provider_name="NIM") + + +def build_nim_request_body( + request_data: MessagesRequest, nim: NimSettings, *, thinking_enabled: bool +) -> dict[str, Any]: + """Build OpenAI-format request body from Anthropic request plus NIM settings.""" + return build_openai_chat_request_body( + request_data, + thinking_enabled=thinking_enabled, + policy=_REQUEST_POLICY, + postprocessors=( + lambda body, request, enabled: apply_nim_request_options( + body, + request, + enabled, + nim=nim, + ), + ), + ) + + +def apply_nim_request_options( + body: dict[str, Any], + request_data: MessagesRequest, + thinking_enabled: bool, + *, + nim: NimSettings, +) -> None: + """Apply NIM schema repairs and configured request defaults.""" + sanitize_nim_tool_schemas(body) + + max_tokens = body.get("max_tokens") or request_data.max_tokens + if max_tokens is None: + max_tokens = nim.max_tokens + elif nim.max_tokens: + max_tokens = min(max_tokens, nim.max_tokens) + set_if_not_none(body, "max_tokens", max_tokens) + + if body.get("temperature") is None and nim.temperature is not None: + body["temperature"] = nim.temperature + if body.get("top_p") is None and nim.top_p is not None: + body["top_p"] = nim.top_p + + if "stop" not in body and nim.stop: + body["stop"] = nim.stop + + if nim.presence_penalty != 0.0: + body["presence_penalty"] = nim.presence_penalty + if nim.frequency_penalty != 0.0: + body["frequency_penalty"] = nim.frequency_penalty + if nim.seed is not None: + body["seed"] = nim.seed + + body["parallel_tool_calls"] = nim.parallel_tool_calls + + extra_body: dict[str, Any] = {} + request_extra = request_data.extra_body + if request_extra: + extra_body.update(request_extra) + + if thinking_enabled: + chat_template_kwargs = extra_body.setdefault( + "chat_template_kwargs", {"thinking": True, "enable_thinking": True} + ) + if isinstance(chat_template_kwargs, dict): + chat_template_kwargs.setdefault("reasoning_budget", max_tokens) + + req_top_k = request_data.top_k + top_k = req_top_k if req_top_k is not None else nim.top_k + _set_extra(extra_body, "top_k", top_k, ignore_value=-1) + _set_extra(extra_body, "min_p", nim.min_p, ignore_value=0.0) + _set_extra( + extra_body, "repetition_penalty", nim.repetition_penalty, ignore_value=1.0 + ) + _set_extra(extra_body, "min_tokens", nim.min_tokens, ignore_value=0) + _set_extra(extra_body, "chat_template", nim.chat_template) + _set_extra(extra_body, "request_id", nim.request_id) + _set_extra(extra_body, "ignore_eos", nim.ignore_eos) + + if extra_body: + body["extra_body"] = extra_body + + +def _set_extra( + extra_body: dict[str, Any], key: str, value: Any, ignore_value: Any = None +) -> None: + if key in extra_body: + return + if value is None: + return + if ignore_value is not None and value == ignore_value: + return + extra_body[key] = value diff --git a/src/free_claude_code/providers/nvidia_nim/retry.py b/src/free_claude_code/providers/nvidia_nim/retry.py new file mode 100644 index 0000000..335a0b6 --- /dev/null +++ b/src/free_claude_code/providers/nvidia_nim/retry.py @@ -0,0 +1,72 @@ +"""NVIDIA NIM retry-body downgrade helpers.""" + +from collections.abc import Callable +from copy import deepcopy +from typing import Any + + +def clone_body_without_reasoning_budget(body: dict[str, Any]) -> dict[str, Any] | None: + """Clone a request body and strip only reasoning_budget fields.""" + return _clone_strip_extra_body(body, _strip_reasoning_budget_fields) + + +def clone_body_without_chat_template(body: dict[str, Any]) -> dict[str, Any] | None: + """Clone a request body and strip NIM chat-template control fields.""" + return _clone_strip_extra_body(body, _strip_chat_template_fields) + + +def clone_body_without_reasoning_content( + body: dict[str, Any], +) -> dict[str, Any] | None: + """Clone a request body and strip assistant message ``reasoning_content`` fields.""" + cloned_body = deepcopy(body) + if not _strip_message_reasoning_content(cloned_body): + return None + return cloned_body + + +def _clone_strip_extra_body( + body: dict[str, Any], + strip: Callable[[dict[str, Any]], bool], +) -> dict[str, Any] | None: + cloned_body = deepcopy(body) + extra_body = cloned_body.get("extra_body") + if not isinstance(extra_body, dict): + return None + if not strip(extra_body): + return None + if not extra_body: + cloned_body.pop("extra_body", None) + return cloned_body + + +def _strip_reasoning_budget_fields(extra_body: dict[str, Any]) -> bool: + removed = extra_body.pop("reasoning_budget", None) is not None + chat_template_kwargs = extra_body.get("chat_template_kwargs") + if ( + isinstance(chat_template_kwargs, dict) + and chat_template_kwargs.pop("reasoning_budget", None) is not None + ): + removed = True + return removed + + +def _strip_chat_template_fields(extra_body: dict[str, Any]) -> bool: + removed = extra_body.pop("chat_template", None) is not None + if extra_body.pop("chat_template_kwargs", None) is not None: + removed = True + return removed + + +def _strip_message_reasoning_content(body: dict[str, Any]) -> bool: + removed = False + messages = body.get("messages") + if not isinstance(messages, list): + return False + for message in messages: + if ( + isinstance(message, dict) + and message.pop("reasoning_content", None) is not None + ): + removed = True + return removed diff --git a/src/free_claude_code/providers/nvidia_nim/tool_schema.py b/src/free_claude_code/providers/nvidia_nim/tool_schema.py new file mode 100644 index 0000000..11186e8 --- /dev/null +++ b/src/free_claude_code/providers/nvidia_nim/tool_schema.py @@ -0,0 +1,255 @@ +"""NVIDIA NIM tool schema sanitization and private argument aliases.""" + +from typing import Any + +_SCHEMA_VALUE_KEYS = frozenset( + { + "additionalProperties", + "additionalItems", + "unevaluatedProperties", + "unevaluatedItems", + "items", + "contains", + "propertyNames", + "if", + "then", + "else", + "not", + } +) +_SCHEMA_LIST_KEYS = frozenset({"allOf", "anyOf", "oneOf", "prefixItems"}) +_SCHEMA_MAP_KEYS = frozenset( + {"properties", "patternProperties", "$defs", "definitions", "dependentSchemas"} +) +NIM_TOOL_ARGUMENT_ALIASES_KEY = "_fcc_nim_tool_argument_aliases" +_NIM_TOOL_PARAMETER_ALIAS_PREFIX = "_fcc_arg_" +_NIM_UNSAFE_TOOL_PARAMETER_NAMES = frozenset({"type"}) + + +def sanitize_nim_tool_schemas(body: dict[str, Any]) -> None: + """Sanitize only tool parameter schemas, preserving tool calls/history.""" + tools = body.get("tools") + if not isinstance(tools, list): + return + + tool_argument_aliases: dict[str, dict[str, str]] = {} + sanitized_tools: list[Any] = [] + for tool in tools: + if not isinstance(tool, dict): + sanitized_tools.append(tool) + continue + sanitized_tool = dict(tool) + function = tool.get("function") + if isinstance(function, dict): + sanitized_function = dict(function) + parameters = function.get("parameters") + if isinstance(parameters, dict): + _, sanitized_parameters = _sanitize_nim_schema_node(parameters) + sanitized_parameters, argument_aliases = _alias_nim_tool_parameters( + sanitized_parameters + ) + sanitized_function["parameters"] = sanitized_parameters + tool_name = function.get("name") + if argument_aliases and isinstance(tool_name, str) and tool_name: + tool_argument_aliases[tool_name] = argument_aliases + sanitized_tool["function"] = sanitized_function + sanitized_tools.append(sanitized_tool) + + body["tools"] = sanitized_tools + if tool_argument_aliases: + body[NIM_TOOL_ARGUMENT_ALIASES_KEY] = tool_argument_aliases + else: + body.pop(NIM_TOOL_ARGUMENT_ALIASES_KEY, None) + + +def nim_tool_argument_aliases_from_body( + body: dict[str, Any], +) -> dict[str, dict[str, str]]: + """Return validated private NIM tool argument aliases from a built body.""" + raw_aliases = body.get(NIM_TOOL_ARGUMENT_ALIASES_KEY) + if not isinstance(raw_aliases, dict): + return {} + + aliases: dict[str, dict[str, str]] = {} + for tool_name, tool_aliases in raw_aliases.items(): + if not isinstance(tool_name, str) or not isinstance(tool_aliases, dict): + continue + sanitized_aliases = { + alias: original + for alias, original in tool_aliases.items() + if isinstance(alias, str) and isinstance(original, str) + } + if sanitized_aliases: + aliases[tool_name] = sanitized_aliases + return aliases + + +def body_without_nim_tool_argument_aliases(body: dict[str, Any]) -> dict[str, Any]: + """Return a request body with private alias metadata stripped before upstream I/O.""" + if NIM_TOOL_ARGUMENT_ALIASES_KEY not in body: + return body + upstream_body = dict(body) + upstream_body.pop(NIM_TOOL_ARGUMENT_ALIASES_KEY, None) + return upstream_body + + +def _sanitize_nim_schema_node(value: Any) -> tuple[bool, Any]: + """Remove boolean JSON Schema subschemas that hosted NIM rejects.""" + if isinstance(value, bool): + return False, None + if isinstance(value, dict): + sanitized: dict[str, Any] = {} + for key, item in value.items(): + if key in _SCHEMA_VALUE_KEYS: + keep, sanitized_item = _sanitize_nim_schema_node(item) + if keep: + sanitized[key] = sanitized_item + elif key in _SCHEMA_LIST_KEYS and isinstance(item, list): + sanitized_items: list[Any] = [] + for schema_item in item: + keep, sanitized_item = _sanitize_nim_schema_node(schema_item) + if keep: + sanitized_items.append(sanitized_item) + if sanitized_items: + sanitized[key] = sanitized_items + elif key in _SCHEMA_MAP_KEYS and isinstance(item, dict): + sanitized_map: dict[str, Any] = {} + for map_key, schema_item in item.items(): + keep, sanitized_item = _sanitize_nim_schema_node(schema_item) + if keep: + sanitized_map[map_key] = sanitized_item + sanitized[key] = sanitized_map + else: + sanitized[key] = item + return True, sanitized + if isinstance(value, list): + sanitized_items = [] + for item in value: + keep, sanitized_item = _sanitize_nim_schema_node(item) + if keep: + sanitized_items.append(sanitized_item) + return True, sanitized_items + return True, value + + +def _needs_nim_tool_parameter_alias(name: str) -> bool: + return name in _NIM_UNSAFE_TOOL_PARAMETER_NAMES + + +def _make_nim_tool_parameter_alias(name: str, reserved: set[str]) -> str: + safe_tail = "".join( + character if character.isalnum() or character == "_" else "_" + for character in name + ).strip("_") + if not safe_tail: + safe_tail = "arg" + candidate = f"{_NIM_TOOL_PARAMETER_ALIAS_PREFIX}{safe_tail}" + alias = candidate + suffix = 2 + while alias in reserved: + alias = f"{candidate}_{suffix}" + suffix += 1 + reserved.add(alias) + return alias + + +def _collect_nim_tool_property_names(value: Any) -> set[str]: + names: set[str] = set() + if isinstance(value, dict): + properties = value.get("properties") + if isinstance(properties, dict): + for property_name, property_schema in properties.items(): + if isinstance(property_name, str): + names.add(property_name) + names.update(_collect_nim_tool_property_names(property_schema)) + for key, item in value.items(): + if key != "properties": + names.update(_collect_nim_tool_property_names(item)) + elif isinstance(value, list): + for item in value: + names.update(_collect_nim_tool_property_names(item)) + return names + + +def _alias_nim_schema_property_names( + value: Any, + *, + reserved: set[str], + alias_to_original: dict[str, str], + original_to_alias: dict[str, str], +) -> Any: + if isinstance(value, list): + return [ + _alias_nim_schema_property_names( + item, + reserved=reserved, + alias_to_original=alias_to_original, + original_to_alias=original_to_alias, + ) + for item in value + ] + if not isinstance(value, dict): + return value + + local_aliases: dict[str, str] = {} + aliased_value: dict[str, Any] = {} + properties = value.get("properties") + if isinstance(properties, dict): + aliased_properties: dict[str, Any] = {} + for property_name, property_schema in properties.items(): + aliased_schema = _alias_nim_schema_property_names( + property_schema, + reserved=reserved, + alias_to_original=alias_to_original, + original_to_alias=original_to_alias, + ) + if isinstance(property_name, str) and _needs_nim_tool_parameter_alias( + property_name + ): + alias = original_to_alias.get(property_name) + if alias is None: + alias = _make_nim_tool_parameter_alias(property_name, reserved) + alias_to_original[alias] = property_name + original_to_alias[property_name] = alias + local_aliases[property_name] = alias + aliased_properties[alias] = aliased_schema + else: + aliased_properties[property_name] = aliased_schema + aliased_value["properties"] = aliased_properties + + for key, item in value.items(): + if key == "properties": + continue + if key == "required" and isinstance(item, list): + aliased_value[key] = [ + local_aliases.get(required_item, required_item) + if isinstance(required_item, str) + else required_item + for required_item in item + ] + continue + aliased_value[key] = _alias_nim_schema_property_names( + item, + reserved=reserved, + alias_to_original=alias_to_original, + original_to_alias=original_to_alias, + ) + + return aliased_value + + +def _alias_nim_tool_parameters( + parameters: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, str]]: + alias_to_original: dict[str, str] = {} + original_to_alias: dict[str, str] = {} + reserved = _collect_nim_tool_property_names(parameters) + aliased_parameters = _alias_nim_schema_property_names( + parameters, + reserved=reserved, + alias_to_original=alias_to_original, + original_to_alias=original_to_alias, + ) + if not alias_to_original: + return parameters, {} + return aliased_parameters, alias_to_original diff --git a/src/free_claude_code/providers/nvidia_nim/voice.py b/src/free_claude_code/providers/nvidia_nim/voice.py new file mode 100644 index 0000000..89496f7 --- /dev/null +++ b/src/free_claude_code/providers/nvidia_nim/voice.py @@ -0,0 +1,113 @@ +"""NVIDIA NIM / Riva offline ASR for voice notes (provider-owned transport).""" + +import asyncio +from pathlib import Path + +from loguru import logger + +# NVIDIA NIM Whisper model mapping: (function_id, language_code) +_NIM_ASR_MODEL_MAP: dict[str, tuple[str, str]] = { + "nvidia/parakeet-ctc-0.6b-zh-tw": ("8473f56d-51ef-473c-bb26-efd4f5def2bf", "zh-TW"), + "nvidia/parakeet-ctc-0.6b-zh-cn": ("9add5ef7-322e-47e0-ad7a-5653fb8d259b", "zh-CN"), + # function-id from NVIDIA NIM API docs (parakeet-ctc-0.6b-es). + "nvidia/parakeet-ctc-0.6b-es": ("a9eeee8f-b509-4712-b19d-194361fa5f31", "es-US"), + "nvidia/parakeet-ctc-0.6b-vi": ("f3dff2bb-99f9-403d-a5f1-f574a757deb0", "vi-VN"), + "nvidia/parakeet-ctc-1.1b-asr": ("1598d209-5e27-4d3c-8079-4751568b1081", "en-US"), + "nvidia/parakeet-ctc-0.6b-asr": ("d8dd4e9b-fbf5-4fb0-9dba-8cf436c8d965", "en-US"), + "nvidia/parakeet-1.1b-rnnt-multilingual-asr": ( + "71203149-d3b7-4460-8231-1be2543a1fca", + "", + ), + "openai/whisper-large-v3": ("b702f636-f60c-4a3d-a6f4-f3568c13bd7d", "multi"), +} + +_RIVA_SERVER = "grpc.nvcf.nvidia.com:443" + + +class NvidiaNimTranscriber: + """Own configured NVIDIA NIM / Riva transcription.""" + + def __init__(self, *, model: str, api_key: str) -> None: + self._model = model + self._key = api_key.strip() + self._lock = asyncio.Lock() + self._closed = False + + async def transcribe(self, file_path: Path) -> str: + """Transcribe one audio file without blocking the event loop.""" + async with self._lock: + if self._closed: + raise RuntimeError("NVIDIA NIM transcriber is closed.") + worker = asyncio.create_task( + asyncio.to_thread(self._transcribe_sync, file_path) + ) + try: + return await asyncio.shield(worker) + except asyncio.CancelledError: + await _wait_for_thread_exit(worker) + raise + + async def close(self) -> None: + """Close this stateless adapter to future work.""" + self._closed = True + async with self._lock: + self._key = "" + + def _transcribe_sync(self, file_path: Path) -> str: + if not self._key: + raise ValueError( + "NVIDIA NIM transcription requires a non-empty " + "nvidia_nim_api_key (configure NVIDIA_NIM_API_KEY)." + ) + model_config = _NIM_ASR_MODEL_MAP.get(self._model) + if model_config is None: + raise ValueError( + f"No NVIDIA NIM config found for model: {self._model}. " + f"Supported models: {', '.join(_NIM_ASR_MODEL_MAP)}" + ) + function_id, language_code = model_config + try: + import riva.client + except ImportError as exc: + raise ImportError( + "NVIDIA NIM transcription requires the voice extra. " + "Install with: uv sync --extra voice" + ) from exc + + auth = riva.client.Auth( + use_ssl=True, + uri=_RIVA_SERVER, + metadata_args=[ + ["function-id", function_id], + ["authorization", f"Bearer {self._key}"], + ], + ) + try: + asr_service = riva.client.ASRService(auth) + config = riva.client.RecognitionConfig( + language_code=language_code, + max_alternatives=1, + verbatim_transcripts=True, + ) + data = file_path.read_bytes() + response = asr_service.offline_recognize(data, config) + + transcript = "" + results = getattr(response, "results", None) + if results and results[0].alternatives: + transcript = results[0].alternatives[0].transcript + logger.debug("NIM transcription: {} chars", len(transcript)) + return transcript or "(no speech detected)" + finally: + auth.channel.close() + + +async def _wait_for_thread_exit(worker: asyncio.Task[str]) -> None: + """Wait through repeated caller cancellation without cancelling thread work.""" + while not worker.done(): + try: + await asyncio.shield(asyncio.wait((worker,))) + except asyncio.CancelledError: + continue + if not worker.cancelled(): + worker.exception() diff --git a/src/free_claude_code/providers/open_router/__init__.py b/src/free_claude_code/providers/open_router/__init__.py new file mode 100644 index 0000000..f4f9418 --- /dev/null +++ b/src/free_claude_code/providers/open_router/__init__.py @@ -0,0 +1,5 @@ +"""OpenRouter provider exports.""" + +from .client import OpenRouterProvider + +__all__ = ["OpenRouterProvider"] diff --git a/src/free_claude_code/providers/open_router/client.py b/src/free_claude_code/providers/open_router/client.py new file mode 100644 index 0000000..d45abfd --- /dev/null +++ b/src/free_claude_code/providers/open_router/client.py @@ -0,0 +1,231 @@ +"""OpenRouter provider implementation.""" + +import json +from collections.abc import Iterator, Mapping, Sequence +from typing import Any + +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.core.anthropic.models import MessagesRequest, ThinkingConfig +from free_claude_code.core.anthropic.streaming import AnthropicStreamLedger +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.model_listing import ( + extract_openrouter_tool_model_ids, + extract_openrouter_tool_model_infos, +) +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, + build_openai_chat_request_body, + validate_extra_body_does_not_override_canonical_fields, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +_REQUEST_POLICY = OpenAIChatRequestPolicy( + provider_name="OPENROUTER", + include_extra_body=True, + extra_body_validator=validate_extra_body_does_not_override_canonical_fields, + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, +) +_PROFILE = OpenAIChatProfile(_REQUEST_POLICY) + + +class OpenRouterProvider(OpenAIChatProvider): + """OpenRouter provider using the OpenAI-compatible Chat Completions API.""" + + def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter): + super().__init__( + config, + profile=_PROFILE, + rate_limiter=rate_limiter, + ) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + effective_thinking_enabled = self._is_thinking_enabled( + request, thinking_enabled + ) + return build_openai_chat_request_body( + request, + thinking_enabled=effective_thinking_enabled, + policy=_REQUEST_POLICY, + postprocessors=( + _apply_openrouter_reasoning_policy, + _apply_openrouter_reasoning_details_replay, + ), + ) + + async def list_model_ids(self) -> frozenset[str]: + """Only advertise OpenRouter models that can run Claude Code tools.""" + payload = await self._client.models.list() + return extract_openrouter_tool_model_ids( + payload, provider_name=self._provider_name + ) + + async def list_model_infos(self) -> frozenset[ProviderModelInfo]: + """Advertise OpenRouter tool models with reasoning capability metadata.""" + payload = await self._client.models.list() + return extract_openrouter_tool_model_infos( + payload, provider_name=self._provider_name + ) + + def _handle_extra_reasoning( + self, delta: Any, ledger: AnthropicStreamLedger, *, thinking_enabled: bool + ) -> Iterator[str]: + """Map OpenRouter reasoning details onto Anthropic thinking blocks.""" + if not thinking_enabled: + return iter(()) + return _iter_openrouter_reasoning_detail_events(delta, ledger) + + +def _apply_openrouter_reasoning_policy( + body: dict[str, Any], request: MessagesRequest, thinking_enabled: bool +) -> None: + if not thinking_enabled: + return + extra_body = body.setdefault("extra_body", {}) + if not isinstance(extra_body, dict): + return + reasoning = extra_body.setdefault("reasoning", {"enabled": True}) + if not isinstance(reasoning, dict): + return + reasoning.setdefault("enabled", True) + budget_tokens = _thinking_budget_tokens(request.thinking) + if isinstance(budget_tokens, int): + reasoning.setdefault("max_tokens", budget_tokens) + + +def _apply_openrouter_reasoning_details_replay( + body: dict[str, Any], request: MessagesRequest, thinking_enabled: bool +) -> None: + if not thinking_enabled: + return + assistant_details = _assistant_reasoning_details(request.messages) + if not assistant_details: + return + messages = body.get("messages") + if not isinstance(messages, list): + return + + cursor = 0 + for details in assistant_details: + for index in range(cursor, len(messages)): + message = messages[index] + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + existing = message.get("reasoning_details") + if isinstance(existing, list): + existing.extend(details) + else: + message["reasoning_details"] = list(details) + cursor = index + 1 + break + + +def _assistant_reasoning_details(messages: Any) -> list[list[dict[str, Any]]]: + if not _is_sequence(messages): + return [] + result: list[list[dict[str, Any]]] = [] + for message in messages: + if _field(message, "role") != "assistant": + continue + details = _redacted_reasoning_details(_field(message, "content")) + if details: + result.append(details) + return result + + +def _redacted_reasoning_details(content: Any) -> list[dict[str, Any]]: + if not _is_sequence(content): + return [] + details: list[dict[str, Any]] = [] + for block in content: + if _field(block, "type") != "redacted_thinking": + continue + data = _field(block, "data") + if not isinstance(data, str) or not data: + continue + parsed = _json_payload(data) + if isinstance(parsed, list): + details.extend(item for item in parsed if isinstance(item, dict)) + elif isinstance(parsed, dict): + details.append(parsed) + else: + details.append({"type": "reasoning.encrypted", "data": data}) + return details + + +def _thinking_budget_tokens(thinking: ThinkingConfig | None) -> int | None: + value = thinking.budget_tokens if thinking is not None else None + return value if isinstance(value, int) and not isinstance(value, bool) else None + + +def _iter_openrouter_reasoning_detail_events( + delta: Any, ledger: AnthropicStreamLedger +) -> Iterator[str]: + details = _field(delta, "reasoning_details") + if details is None: + extra = _field(delta, "model_extra") + if isinstance(extra, Mapping): + details = extra.get("reasoning_details") + if not _is_sequence(details): + return + + native_reasoning = _field(delta, "reasoning_content") + has_native_reasoning = isinstance(native_reasoning, str) and bool(native_reasoning) + for detail in details: + encrypted = _reasoning_detail_encrypted(detail) + if encrypted: + yield from ledger.close_content_blocks() + index = ledger.blocks.allocate_index() + yield ledger.content_block_start(index, "redacted_thinking", data=encrypted) + yield ledger.content_block_stop(index) + continue + if has_native_reasoning: + continue + text = _reasoning_detail_text(detail) + if not text: + continue + yield from ledger.ensure_thinking_block() + yield ledger.emit_thinking_delta(text) + + +def _reasoning_detail_text(detail: Any) -> str | None: + kind = str(_field(detail, "type") or "").lower() + if "encrypted" in kind or "redacted" in kind: + return None + for key in ("text", "content", "reasoning"): + value = _field(detail, key) + if isinstance(value, str) and value: + return value + return None + + +def _reasoning_detail_encrypted(detail: Any) -> str | None: + kind = str(_field(detail, "type") or "").lower() + if "encrypted" not in kind and "redacted" not in kind and "summary" not in kind: + return None + if isinstance(detail, Mapping): + return json.dumps(dict(detail), separators=(",", ":")) + return None + + +def _json_payload(value: str) -> Any: + try: + return json.loads(value) + except json.JSONDecodeError: + return None + + +def _field(item: Any, name: str) -> Any: + if isinstance(item, Mapping): + return item.get(name) + return getattr(item, name, None) + + +def _is_sequence(value: Any) -> bool: + return isinstance(value, Sequence) and not isinstance( + value, str | bytes | bytearray + ) diff --git a/src/free_claude_code/providers/openai_chat/__init__.py b/src/free_claude_code/providers/openai_chat/__init__.py new file mode 100644 index 0000000..fa3ece4 --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/__init__.py @@ -0,0 +1,40 @@ +"""OpenAI-compatible provider family.""" + +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +from .base_url import openai_v1_base_url +from .extra_body import validate_extra_body_does_not_override_canonical_fields +from .profiles import OPENAI_CHAT_PROFILES, OpenAIChatProfile +from .provider import OpenAIChatProvider +from .request_policy import OpenAIChatRequestPolicy, build_openai_chat_request_body +from .usage import usage_int + + +def create_openai_chat_provider( + provider_id: str, + config: ProviderConfig, + rate_limiter: ProviderRateLimiter, +) -> OpenAIChatProvider: + """Construct one profile-driven provider.""" + profile = OPENAI_CHAT_PROFILES.get(provider_id) + if profile is None: + raise KeyError(f"No declarative OpenAI-chat profile for {provider_id!r}") + return OpenAIChatProvider( + config, + profile=profile, + rate_limiter=rate_limiter, + ) + + +__all__ = [ + "OPENAI_CHAT_PROFILES", + "OpenAIChatProfile", + "OpenAIChatProvider", + "OpenAIChatRequestPolicy", + "build_openai_chat_request_body", + "create_openai_chat_provider", + "openai_v1_base_url", + "usage_int", + "validate_extra_body_does_not_override_canonical_fields", +] diff --git a/src/free_claude_code/providers/openai_chat/base_url.py b/src/free_claude_code/providers/openai_chat/base_url.py new file mode 100644 index 0000000..4cda6e8 --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/base_url.py @@ -0,0 +1,7 @@ +"""OpenAI-compatible API base URL policy.""" + + +def openai_v1_base_url(base_url: str) -> str: + """Return the canonical ``/v1`` API base for a server root or API base.""" + normalized = base_url.rstrip("/") + return normalized if normalized.endswith("/v1") else f"{normalized}/v1" diff --git a/src/free_claude_code/providers/openai_chat/extra_body.py b/src/free_claude_code/providers/openai_chat/extra_body.py new file mode 100644 index 0000000..7f08b1f --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/extra_body.py @@ -0,0 +1,32 @@ +"""Validation helpers for OpenAI-chat ``extra_body`` passthrough.""" + +from typing import Any + +CANONICAL_OPENAI_CHAT_BODY_KEYS = frozenset( + { + "model", + "messages", + "tools", + "tool_choice", + "stream", + "max_tokens", + "max_completion_tokens", + "temperature", + "top_p", + "metadata", + "stop", + "stop_sequences", + "stream_options", + } +) + + +def validate_extra_body_does_not_override_canonical_fields( + extra: dict[str, Any], +) -> None: + """Reject extras that would replace FCC-owned chat-completion fields.""" + bad = CANONICAL_OPENAI_CHAT_BODY_KEYS & extra.keys() + if bad: + raise ValueError( + f"extra_body must not override canonical request fields: {sorted(bad)}" + ) diff --git a/src/free_claude_code/providers/openai_chat/output_cap.py b/src/free_claude_code/providers/openai_chat/output_cap.py new file mode 100644 index 0000000..08cea7e --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/output_cap.py @@ -0,0 +1,83 @@ +"""Recover from upstream ``max_(completion_)tokens`` too-large 400 rejections. + +Some OpenAI-compatible providers (Groq, NVIDIA NIM, ...) cap the per-request +output token count below what Claude Code asks for and reject the whole request +with an HTTP 400 that names the allowed maximum, e.g.:: + + max_completion_tokens must be less than or equal to 40960, ... + +This module parses that maximum and clamps the request body so the provider can +retry once and succeed. The provider also remembers the learned cap per model +so later requests clamp proactively instead of paying the 400 every time. +""" + +import json +import re +from typing import Any + +import openai + +# Body keys that carry the output-token budget across OpenAI-compatible policies. +_OUTPUT_TOKEN_FIELDS = ("max_completion_tokens", "max_tokens") + +# Only treat a 400 as an output-cap rejection when it names one of these fields. +_OUTPUT_TOKEN_KEYWORDS = ("max_completion_tokens", "max_tokens") + +# Comparator phrases that precede the allowed maximum in provider error text. +_CAP_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"less than or equal to\s+(\d+)"), + re.compile(r"smaller than or equal to\s+(\d+)"), + re.compile(r"<=\s*(\d+)"), + re.compile(r"at most\s+(\d+)"), + re.compile(r"must not exceed\s+(\d+)"), + re.compile(r"maximum(?:\s+value)?(?:\s+for\s+\S+)?\s+is\s+(\d+)"), + re.compile(r"maximum(?:\s+allowed)?(?:\s+value)?\s+of\s+(\d+)"), +) + + +def _is_bad_request(error: Exception) -> bool: + return isinstance(error, openai.BadRequestError) or ( + getattr(error, "status_code", None) == 400 + ) + + +def _error_text(error: Exception) -> str: + text = str(error) + body = getattr(error, "body", None) + if body is not None: + text = f"{text} {json.dumps(body, default=str)}" + return text.lower() + + +def parse_output_token_cap(error: Exception) -> int | None: + """Return the allowed output-token maximum named in a 400 rejection, if any.""" + if not _is_bad_request(error): + return None + + text = _error_text(error) + if not any(keyword in text for keyword in _OUTPUT_TOKEN_KEYWORDS): + return None + + for pattern in _CAP_PATTERNS: + match = pattern.search(text) + if match: + cap = int(match.group(1)) + if cap > 0: + return cap + return None + + +def clamp_output_tokens(body: dict[str, Any], cap: int) -> dict[str, Any] | None: + """Return a shallow clone with output-token fields clamped to ``cap``. + + Returns ``None`` when nothing needs clamping (no output field exceeds the + cap), so callers can avoid a pointless identical retry. + """ + clamped: dict[str, Any] | None = None + for field in _OUTPUT_TOKEN_FIELDS: + value = body.get(field) + if isinstance(value, int) and not isinstance(value, bool) and value > cap: + if clamped is None: + clamped = dict(body) + clamped[field] = cap + return clamped diff --git a/src/free_claude_code/providers/openai_chat/profiles.py b/src/free_claude_code/providers/openai_chat/profiles.py new file mode 100644 index 0000000..2dff029 --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/profiles.py @@ -0,0 +1,229 @@ +"""Declarative profiles for providers with no adapter-specific runtime behavior.""" + +from collections.abc import Mapping +from copy import deepcopy +from dataclasses import dataclass +from typing import Any + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.core.anthropic import ReasoningReplayMode +from free_claude_code.core.anthropic.models import MessagesRequest + +from .base_url import openai_v1_base_url +from .extra_body import validate_extra_body_does_not_override_canonical_fields +from .request_policy import OpenAIChatPostprocessor, OpenAIChatRequestPolicy + + +@dataclass(frozen=True, slots=True) +class OpenAIChatProfile: + """Immutable behavior differences for one ordinary OpenAI-chat provider.""" + + request_policy: OpenAIChatRequestPolicy + postprocessors: tuple[OpenAIChatPostprocessor, ...] = () + normalize_base_url: bool = False + + @property + def provider_name(self) -> str: + return self.request_policy.provider_name + + def base_url(self, configured: str) -> str: + return openai_v1_base_url(configured) if self.normalize_base_url else configured + + +def _apply_cohere_request_quirks( + body: dict[str, Any], request: MessagesRequest, thinking_enabled: bool +) -> None: + _merge_allowed_cohere_extra_body(body, request.extra_body) + body["reasoning_effort"] = "high" if thinking_enabled else "none" + + +_COHERE_EXTRA_BODY_KEYS = frozenset( + { + "frequency_penalty", + "presence_penalty", + "response_format", + "seed", + } +) + + +def _merge_allowed_cohere_extra_body(body: dict[str, Any], extra_body: Any) -> None: + if extra_body in (None, {}): + return + if not isinstance(extra_body, Mapping): + raise InvalidRequestError("Cohere extra_body must be an object when provided.") + + unsupported = sorted( + str(key) for key in extra_body if key not in _COHERE_EXTRA_BODY_KEYS + ) + if unsupported: + raise InvalidRequestError( + "Cohere extra_body supports only these keys: " + f"{sorted(_COHERE_EXTRA_BODY_KEYS)}. Unsupported: {unsupported}" + ) + body.update({str(key): deepcopy(value) for key, value in extra_body.items()}) + + +def _apply_kimi_thinking_policy( + body: dict[str, Any], _request: MessagesRequest, thinking_enabled: bool +) -> None: + if thinking_enabled: + return + extra_body = body.setdefault("extra_body", {}) + if isinstance(extra_body, dict): + extra_body["thinking"] = {"type": "disabled"} + + +def _apply_minimax_thinking_policy( + body: dict[str, Any], _request: MessagesRequest, thinking_enabled: bool +) -> None: + extra_body = body.setdefault("extra_body", {}) + if not isinstance(extra_body, dict): + return + extra_body["reasoning_split"] = True + extra_body["thinking"] = ( + {"type": "adaptive"} if thinking_enabled else {"type": "disabled"} + ) + + +def _apply_wafer_thinking_policy( + body: dict[str, Any], _request: MessagesRequest, thinking_enabled: bool +) -> None: + extra_body = body.setdefault("extra_body", {}) + if isinstance(extra_body, dict): + extra_body["thinking"] = ( + {"type": "enabled"} if thinking_enabled else {"type": "disabled"} + ) + + +def _apply_zai_thinking_policy( + body: dict[str, Any], _request: MessagesRequest, thinking_enabled: bool +) -> None: + extra_body = body.setdefault("extra_body", {}) + if not isinstance(extra_body, dict): + return + extra_body["thinking"] = ( + {"type": "enabled", "clear_thinking": False} + if thinking_enabled + else {"type": "disabled"} + ) + + +OPENAI_CHAT_PROFILES: dict[str, OpenAIChatProfile] = { + "mistral_codestral": OpenAIChatProfile( + OpenAIChatRequestPolicy(provider_name="CODESTRAL") + ), + "opencode": OpenAIChatProfile(OpenAIChatRequestPolicy(provider_name="OPENCODE")), + "opencode_go": OpenAIChatProfile( + OpenAIChatRequestPolicy(provider_name="OPENCODE_GO") + ), + "vercel": OpenAIChatProfile( + OpenAIChatRequestPolicy(provider_name="VERCEL", include_extra_body=True) + ), + "huggingface": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="HUGGINGFACE", + include_extra_body=True, + reasoning_replay=ReasoningReplayMode.DISABLED, + ) + ), + "cohere": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="COHERE", + strip_message_names=True, + unsupported_body_keys=frozenset( + { + "audio", + "logit_bias", + "metadata", + "modalities", + "n", + "parallel_tool_calls", + "prediction", + "service_tier", + "store", + "top_logprobs", + } + ), + ), + postprocessors=(_apply_cohere_request_quirks,), + ), + "wafer": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="WAFER", + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ), + postprocessors=(_apply_wafer_thinking_policy,), + ), + "kimi": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="KIMI", + reject_extra_body_message=( + "Kimi Chat Completions API does not support caller extra_body on requests." + ), + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ), + postprocessors=(_apply_kimi_thinking_policy,), + ), + "minimax": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="MINIMAX", + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + max_tokens_field="max_completion_tokens", + ), + postprocessors=(_apply_minimax_thinking_policy,), + ), + "cerebras": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="CEREBRAS", + include_extra_body=True, + max_tokens_field="max_completion_tokens", + ) + ), + "groq": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="GROQ", + include_extra_body=True, + max_tokens_field="max_completion_tokens", + strip_message_names=True, + unsupported_body_keys=frozenset({"logprobs", "logit_bias", "top_logprobs"}), + normalize_n_to_one=True, + ) + ), + "sambanova": OpenAIChatProfile( + OpenAIChatRequestPolicy(provider_name="SAMBANOVA", include_extra_body=True) + ), + "fireworks": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="FIREWORKS", + include_extra_body=True, + extra_body_validator=validate_extra_body_does_not_override_canonical_fields, + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ) + ), + "zai": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="ZAI", + reject_extra_body_message=( + "Z.ai Chat Completions API does not support caller extra_body on requests." + ), + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ), + postprocessors=(_apply_zai_thinking_policy,), + ), + "llamacpp": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="LLAMACPP", + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ), + normalize_base_url=True, + ), + "ollama": OpenAIChatProfile( + OpenAIChatRequestPolicy( + provider_name="OLLAMA", + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ), + normalize_base_url=True, + ), +} diff --git a/src/free_claude_code/providers/openai_chat/provider.py b/src/free_claude_code/providers/openai_chat/provider.py new file mode 100644 index 0000000..f7d7549 --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/provider.py @@ -0,0 +1,837 @@ +"""Concrete OpenAI-compatible provider and per-request stream execution.""" + +import asyncio +import sys +import uuid +from collections.abc import AsyncIterator, Iterator, Mapping +from typing import Any + +import httpx +from loguru import logger +from openai import AsyncOpenAI + +from free_claude_code.core.anthropic import ( + ContentType, + HeuristicToolParser, + ThinkTagParser, +) +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.anthropic.streaming import ( + AnthropicStreamLedger, + accept_tool_json_repair, + continuation_suffix, + make_text_recovery_body, + make_tool_repair_body, + map_stop_reason, + parse_complete_tool_input, + tool_schemas_by_name, +) +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.core.trace import provider_chat_body_snapshot, trace_event +from free_claude_code.providers.base import BaseProvider, ProviderConfig +from free_claude_code.providers.failure_policy import classify_provider_failure +from free_claude_code.providers.http import ( + close_provider_stream, + maybe_await_aclose, +) +from free_claude_code.providers.model_listing import extract_openai_model_ids +from free_claude_code.providers.rate_limit import ProviderRateLimiter +from free_claude_code.providers.stream_recovery import ( + MIDSTREAM_RECOVERY_ATTEMPTS, + RecoveryController, + RecoveryFailureAction, + TruncatedProviderStreamError, + is_retryable_stream_error, +) + +from .output_cap import clamp_output_tokens, parse_output_token_cap +from .profiles import OpenAIChatProfile +from .request_policy import build_openai_chat_request_body +from .tool_calls import ( + OpenAIToolCallAssembler, + all_emitted_tools_complete, + has_committed_sse_output, + iter_heuristic_tool_use_sse, + started_tool_states, + tool_call_extra_content, +) +from .usage import ( + clone_without_stream_usage, + is_stream_usage_rejection, + request_stream_usage, + usage_int, +) + + +class OpenAIChatProvider(BaseProvider): + """OpenAI-compatible ``/chat/completions`` provider configured by a profile.""" + + def __init__( + self, + config: ProviderConfig, + *, + profile: OpenAIChatProfile, + rate_limiter: ProviderRateLimiter, + default_headers: Mapping[str, str] | None = None, + ): + super().__init__(config) + self._profile = profile + self._provider_name = profile.provider_name + self._api_key = config.api_key + self._base_url = profile.base_url(config.base_url).rstrip("/") + # Learned per-model output-token caps from upstream 400 rejections, so + # later requests clamp proactively instead of paying the 400 each time. + self._model_output_caps: dict[str, int] = {} + self._rate_limiter = rate_limiter + http_client = None + if config.proxy: + http_client = httpx.AsyncClient( + proxy=config.proxy, + timeout=httpx.Timeout( + config.http_read_timeout, + connect=config.http_connect_timeout, + read=config.http_read_timeout, + write=config.http_write_timeout, + ), + ) + self._client = AsyncOpenAI( + api_key=self._api_key, + base_url=self._base_url, + max_retries=0, + default_headers=default_headers, + timeout=httpx.Timeout( + config.http_read_timeout, + connect=config.http_connect_timeout, + read=config.http_read_timeout, + write=config.http_write_timeout, + ), + http_client=http_client, + ) + + async def cleanup(self) -> None: + """Release HTTP client resources.""" + client = getattr(self, "_client", None) + if client is not None: + await client.close() + + async def list_model_ids(self) -> frozenset[str]: + """Return model ids from the provider's OpenAI-compatible models endpoint.""" + payload = await self._client.models.list() + return extract_openai_model_ids(payload, provider_name=self._provider_name) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict[str, Any]: + """Build a provider request from the immutable profile.""" + return build_openai_chat_request_body( + request, + thinking_enabled=self._is_thinking_enabled(request, thinking_enabled), + policy=self._profile.request_policy, + postprocessors=self._profile.postprocessors, + ) + + def preflight_stream( + self, request: MessagesRequest, *, thinking_enabled: bool | None = None + ) -> None: + """Validate OpenAI-chat request conversion before streaming.""" + self._build_request_body(request, thinking_enabled=thinking_enabled) + + def _handle_extra_reasoning( + self, delta: Any, ledger: AnthropicStreamLedger, *, thinking_enabled: bool + ) -> Iterator[str]: + """Hook for provider-specific reasoning.""" + return iter(()) + + def _get_retry_request_body(self, error: Exception, body: dict) -> dict | None: + """Return a modified request body for one retry, or None.""" + return None + + def _provider_failure_override(self, error: Exception) -> ExecutionFailure | None: + """Return provider-specific failure semantics, or defer to shared policy.""" + return None + + def _prepare_create_body(self, body: dict[str, Any]) -> dict[str, Any]: + """Return the body passed to the upstream OpenAI-compatible client.""" + return body + + def _record_tool_call_extra_content( + self, tool_call_id: str, extra_content: dict[str, Any] + ) -> None: + """Hook for providers that must replay OpenAI tool-call metadata later.""" + + def _tool_argument_aliases(self, body: dict[str, Any]) -> dict[str, dict[str, str]]: + """Return provider-specific per-tool argument aliases for this request.""" + return {} + + def _anthropic_usage_fields(self, usage_info: Any) -> dict[str, int]: + """Return provider-specific Anthropic usage fields for final SSE usage.""" + return {} + + async def _create_stream(self, body: dict) -> tuple[Any, dict]: + """Create a streaming chat completion with bounded request fallbacks.""" + body = self._apply_learned_output_cap(body) + used_retry_kinds: set[str] = set() + + while True: + try: + create_body = self._prepare_create_body(body) + stream = await self._rate_limiter.execute_with_retry( + self._client.chat.completions.create, + provider_failure_override=self._provider_failure_override, + **create_body, + stream=True, + ) + return stream, body + except Exception as error: + retry_body = self._next_create_retry_body(error, body, used_retry_kinds) + if retry_body is None: + raise + body = retry_body + + def _next_create_retry_body( + self, + error: Exception, + body: dict, + used_retry_kinds: set[str], + ) -> dict | None: + retry_body = self._retry_body_for_output_cap(error, body) + if retry_body is not None: + return retry_body + + if "stream_usage" not in used_retry_kinds and is_stream_usage_rejection(error): + retry_body = clone_without_stream_usage(body) + if retry_body is not None: + used_retry_kinds.add("stream_usage") + logger.warning( + "{}_STREAM: retrying without stream_options.include_usage " + "after upstream rejection", + self._provider_name, + ) + return retry_body + + if "provider_specific" not in used_retry_kinds: + retry_body = self._get_retry_request_body(error, body) + if retry_body is not None: + used_retry_kinds.add("provider_specific") + return retry_body + + return None + + def _apply_learned_output_cap(self, body: dict) -> dict: + """Clamp output tokens to a previously learned cap for this model.""" + model = body.get("model") + if not isinstance(model, str): + return body + cap = self._model_output_caps.get(model) + if cap is None: + return body + clamped = clamp_output_tokens(body, cap) + return clamped if clamped is not None else body + + def _retry_body_for_output_cap(self, error: Exception, body: dict) -> dict | None: + """Learn an upstream output-token cap from a 400 and clamp for one retry.""" + cap = parse_output_token_cap(error) + if cap is None: + return None + model = body.get("model") + if isinstance(model, str): + previous = self._model_output_caps.get(model) + cap = cap if previous is None else min(previous, cap) + self._model_output_caps[model] = cap + clamped = clamp_output_tokens(body, cap) + if clamped is None: + return None + logger.warning( + "{}_STREAM: clamping output tokens to {} after upstream cap rejection", + self._provider_name, + cap, + ) + return clamped + + def stream_response( + self, + request: MessagesRequest, + input_tokens: int = 0, + *, + request_id: str | None = None, + thinking_enabled: bool | None = None, + ) -> AsyncIterator[str]: + """Stream response in Anthropic SSE format.""" + runner = _OpenAIChatStreamRunner( + self, + request=request, + input_tokens=input_tokens, + request_id=request_id, + thinking_enabled=thinking_enabled, + ) + return runner.run() + + +class _OpenAIChatStreamRunner: + """Own one OpenAI-chat request's stream, parsing, and recovery state.""" + + def __init__( + self, + provider: OpenAIChatProvider, + *, + request: MessagesRequest, + input_tokens: int, + request_id: str | None, + thinking_enabled: bool | None, + ) -> None: + self._provider = provider + self._request = request + self._input_tokens = input_tokens + self._request_id = request_id + self._thinking_enabled = thinking_enabled + self._message_id = f"msg_{uuid.uuid4()}" + self._tool_calls = OpenAIToolCallAssembler( + record_extra_content=provider._record_tool_call_extra_content + ) + + async def run(self) -> AsyncIterator[str]: + """Convert the upstream OpenAI-chat stream into Anthropic SSE.""" + tag = self._provider._provider_name + req_tag = f" request_id={self._request_id}" if self._request_id else "" + ledger = self._new_ledger() + recovery = RecoveryController( + provider_name=tag, + request_id=self._request_id, + ) + + def hold_event(event: str) -> Iterator[str]: + yield from recovery.push(event) + + def hold_events(events: Iterator[str]) -> Iterator[str]: + for event in events: + yield from hold_event(event) + + body = self._provider._build_request_body( + self._request, thinking_enabled=self._thinking_enabled + ) + request_stream_usage(body) + thinking_enabled = self._provider._is_thinking_enabled( + self._request, self._thinking_enabled + ) + trace_event( + stage="provider", + event="provider.request.sent", + source="provider", + provider=tag, + request_id=self._request_id, + gateway_model=self._request.model, + downstream_model=body.get("model"), + message_count=len(body.get("messages", [])), + tool_count=len(body.get("tools", [])), + body=provider_chat_body_snapshot(body), + ) + + think_parser = ThinkTagParser() + heuristic_parser = HeuristicToolParser() + finish_reason = None + usage_info = None + tool_argument_aliases: dict[str, dict[str, str]] = {} + tool_argument_alias_buffers: dict[int, str] = {} + + async with self._provider._rate_limiter.concurrency_slot(): + while True: + if not ledger.message_started: + for event in hold_event(ledger.message_start()): + yield event + stream: Any | None = None + stream_opened = False + try: + stream, body = await self._provider._create_stream(body) + stream_opened = True + tool_argument_aliases = self._provider._tool_argument_aliases(body) + async for chunk in stream: + chunk_usage = getattr(chunk, "usage", None) + if chunk_usage is not None: + usage_info = chunk_usage + + if not chunk.choices: + continue + + choice = chunk.choices[0] + delta = choice.delta + if delta is None: + continue + + if choice.finish_reason: + finish_reason = choice.finish_reason + logger.debug("{} finish_reason: {}", tag, finish_reason) + + reasoning = getattr(delta, "reasoning_content", None) + if thinking_enabled and isinstance(reasoning, str): + for event in hold_events(ledger.ensure_thinking_block()): + yield event + if reasoning: + for event in hold_event( + ledger.emit_thinking_delta(reasoning) + ): + yield event + + for event in self._provider._handle_extra_reasoning( + delta, + ledger, + thinking_enabled=thinking_enabled, + ): + for out_event in hold_event(event): + yield out_event + + if delta.content: + for part in think_parser.feed(delta.content): + if part.type == ContentType.THINKING: + if not thinking_enabled: + continue + for event in hold_events( + ledger.ensure_thinking_block() + ): + yield event + for event in hold_event( + ledger.emit_thinking_delta(part.content) + ): + yield event + else: + ( + filtered_text, + detected_tools, + ) = heuristic_parser.feed(part.content) + + if filtered_text: + for event in hold_events( + ledger.ensure_text_block() + ): + yield event + for event in hold_event( + ledger.emit_text_delta(filtered_text) + ): + yield event + + for tool_use in detected_tools: + for event in iter_heuristic_tool_use_sse( + ledger, tool_use + ): + for out_event in hold_event(event): + yield out_event + + if delta.tool_calls: + for event in hold_events(ledger.close_content_blocks()): + yield event + for tool_call in delta.tool_calls: + extra_content = tool_call_extra_content(tool_call) + tool_call_info = { + "index": tool_call.index, + "id": tool_call.id, + "function": { + "name": tool_call.function.name, + "arguments": tool_call.function.arguments, + }, + } + if extra_content: + tool_call_info["extra_content"] = extra_content + for event in self._tool_calls.process_tool_call( + tool_call_info, + ledger, + tool_argument_aliases=tool_argument_aliases, + tool_argument_alias_buffers=tool_argument_alias_buffers, + ): + for out_event in hold_event(event): + yield out_event + + if finish_reason is None: + raise TruncatedProviderStreamError( + "Provider stream ended without finish_reason." + ) + break + + except asyncio.CancelledError, GeneratorExit: + raise + except Exception as error: + generated_output = has_committed_sse_output(ledger) + complete_tool_salvageable = ( + generated_output + and ledger.has_emitted_tool_block() + and all_emitted_tools_complete(ledger, self._request) + ) + decision = recovery.advance_failure( + error, + stream_opened=stream_opened, + generated_output=generated_output, + complete_tool_salvageable=complete_tool_salvageable, + ) + if decision.action == RecoveryFailureAction.EARLY_RETRY: + ledger = self._new_ledger() + think_parser = ThinkTagParser() + heuristic_parser = HeuristicToolParser() + finish_reason = None + usage_info = None + tool_argument_aliases = {} + tool_argument_alias_buffers = {} + continue + + if decision.action == RecoveryFailureAction.MIDSTREAM_RECOVERY: + try: + recovery_events = await self._recovery_events( + body=body, + ledger=ledger, + error=error, + tool_argument_alias_buffers=tool_argument_alias_buffers, + ) + except Exception as recovery_error: + trace_event( + stage="provider", + event="provider.recovery.failed", + source="provider", + provider=tag, + request_id=self._request_id, + exc_type=type(recovery_error).__name__, + ) + recovery_events = None + if recovery_events is not None: + for event in recovery.flush_uncommitted(decision): + yield event + for event in recovery_events: + yield event + return + + self._provider._log_stream_transport_error( + tag, req_tag, error, request_id=self._request_id + ) + failure = classify_provider_failure( + error, + provider_name=tag, + read_timeout_s=self._provider._config.http_read_timeout, + request_id=self._request_id, + mark_rate_limited=( + self._provider._rate_limiter.extend_reactive_block + ), + provider_failure_override=( + self._provider._provider_failure_override + ), + ) + error_trace: dict[str, Any] = { + "stage": "provider", + "event": "provider.response.error", + "source": "provider", + "provider": tag, + "request_id": self._request_id, + "exc_type": type(error).__name__, + "failure_kind": failure.kind.value, + "status_code": failure.status_code, + "provider_retryable": failure.retryable, + } + if self._provider._config.log_api_error_tracebacks: + error_trace["error_message"] = failure.message + trace_event(**error_trace) + if ( + not decision.committed + and decision.has_buffered + and complete_tool_salvageable + ): + for event in recovery.flush(): + yield event + elif not decision.committed: + recovery.discard() + raise failure from error + for event in ledger.close_unclosed_blocks(): + yield event + raise failure from error + finally: + if stream is not None: + await close_provider_stream( + stream, + active_error=sys.exception(), + provider_name=tag, + request_id=self._request_id, + ) + + remaining = think_parser.flush() + if remaining: + if remaining.type == ContentType.THINKING: + if not thinking_enabled: + remaining = None + else: + for event in hold_events(ledger.ensure_thinking_block()): + yield event + for event in hold_event( + ledger.emit_thinking_delta(remaining.content) + ): + yield event + if remaining and remaining.type == ContentType.TEXT: + for event in hold_events(ledger.ensure_text_block()): + yield event + for event in hold_event(ledger.emit_text_delta(remaining.content)): + yield event + + for tool_use in heuristic_parser.flush(): + for event in iter_heuristic_tool_use_sse(ledger, tool_use): + for out_event in hold_event(event): + yield out_event + + has_emitted_tool = ledger.has_emitted_tool_block() + has_content_blocks = ( + ledger.blocks.text_index != -1 + or ledger.blocks.thinking_index != -1 + or has_emitted_tool + ) + if not has_content_blocks or ( + not has_emitted_tool + and not ledger.accumulated_text.strip() + and ledger.accumulated_reasoning.strip() + ): + for event in hold_events(ledger.ensure_text_block()): + yield event + for event in hold_event(ledger.emit_text_delta(" ")): + yield event + + for event in self._tool_calls.flush_tool_argument_alias_buffers( + ledger, tool_argument_aliases, tool_argument_alias_buffers + ): + for out_event in hold_event(event): + yield out_event + + for event in self._tool_calls.flush_task_arg_buffers(ledger): + for out_event in hold_event(event): + yield out_event + + for event in hold_events(ledger.close_all_blocks()): + yield event + + completion = usage_int(usage_info, "completion_tokens") + if isinstance(completion, int): + output_tokens = completion + else: + output_tokens = ledger.estimate_output_tokens() + provider_input = usage_int(usage_info, "prompt_tokens") + if provider_input is not None: + logger.debug( + "TOKEN_ESTIMATE: our={} provider={} diff={:+d}", + self._input_tokens, + provider_input, + provider_input - self._input_tokens, + ) + input_tokens = ( + provider_input if provider_input is not None else self._input_tokens + ) + trace_event( + stage="provider", + event="provider.response.completed", + source="provider", + provider=tag, + request_id=self._request_id, + finish_reason=(None if finish_reason is None else str(finish_reason)), + output_tokens=output_tokens, + prompt_tokens=input_tokens, + prompt_tokens_estimate=self._input_tokens, + ) + for event in hold_event( + ledger.message_delta( + ledger.final_stop_reason(map_stop_reason(finish_reason)), + output_tokens, + input_tokens=input_tokens, + usage_fields=self._provider._anthropic_usage_fields(usage_info), + ) + ): + yield event + for event in hold_event(ledger.message_stop()): + yield event + for event in recovery.flush(): + yield event + + async def _collect_recovery_text(self, body: dict[str, Any]) -> tuple[str, str]: + """Collect a complete text/reasoning continuation stream.""" + last_error: Exception | None = None + for attempt in range(MIDSTREAM_RECOVERY_ATTEMPTS): + stream: Any | None = None + try: + stream, _ = await self._provider._create_stream(body) + text_parts: list[str] = [] + thinking_parts: list[str] = [] + terminal_seen = False + async for chunk in stream: + if not getattr(chunk, "choices", None): + continue + choice = chunk.choices[0] + if choice.finish_reason is not None: + terminal_seen = True + delta = choice.delta + if delta is None: + continue + reasoning = getattr(delta, "reasoning_content", None) + if isinstance(reasoning, str) and reasoning: + thinking_parts.append(reasoning) + content = getattr(delta, "content", None) + if isinstance(content, str) and content: + text_parts.append(content) + if not terminal_seen: + raise TruncatedProviderStreamError( + "Recovery stream ended without finish_reason." + ) + return "".join(text_parts), "".join(thinking_parts) + except Exception as error: + last_error = error + if not is_retryable_stream_error(error): + raise + trace_event( + stage="provider", + event="provider.recovery.retry", + source="provider", + provider=self._provider._provider_name, + recovery_kind="openai_text", + attempt=attempt + 1, + max_attempts=MIDSTREAM_RECOVERY_ATTEMPTS, + exc_type=type(error).__name__, + ) + finally: + if stream is not None: + await maybe_await_aclose(stream) + if last_error is not None: + raise last_error + return "", "" + + async def _recovery_events( + self, + *, + body: dict[str, Any], + ledger: AnthropicStreamLedger, + error: Exception, + tool_argument_alias_buffers: dict[int, str], + ) -> list[str] | None: + """Build terminal recovery events when the interrupted stream permits it.""" + if not is_retryable_stream_error(error): + return None + + if ledger.has_emitted_tool_block(): + if not all_emitted_tools_complete(ledger, self._request): + repair_events = await self._repair_tool_args( + body=body, + ledger=ledger, + tool_argument_alias_buffers=tool_argument_alias_buffers, + ) + if repair_events is None: + return None + else: + repair_events = [] + events = list(repair_events) + events.extend(ledger.close_all_blocks()) + events.append( + ledger.message_delta( + ledger.final_stop_reason("end_turn"), + ledger.estimate_output_tokens(), + ) + ) + events.append(ledger.message_stop()) + trace_event( + stage="provider", + event="provider.recovery.tool_salvaged", + source="provider", + provider=self._provider._provider_name, + request_id=self._request_id, + ) + return events + + partial_text = ledger.accumulated_text + partial_thinking = ledger.accumulated_reasoning + if not partial_text and not partial_thinking: + return None + + recovery_body = make_text_recovery_body(body, partial_text, partial_thinking) + text, thinking = await self._collect_recovery_text(recovery_body) + text_suffix = continuation_suffix(partial_text, text) + thinking_suffix = continuation_suffix(partial_thinking, thinking) + events: list[str] = [] + if thinking_suffix: + events.extend(ledger.ensure_thinking_block()) + events.append(ledger.emit_thinking_delta(thinking_suffix)) + if text_suffix: + events.extend(ledger.ensure_text_block()) + events.append(ledger.emit_text_delta(text_suffix)) + if not events: + return None + events.extend(ledger.close_all_blocks()) + events.append( + ledger.message_delta( + ledger.final_stop_reason("end_turn"), ledger.estimate_output_tokens() + ) + ) + events.append(ledger.message_stop()) + trace_event( + stage="provider", + event="provider.recovery.continued", + source="provider", + provider=self._provider._provider_name, + request_id=self._request_id, + ) + return events + + async def _repair_tool_args( + self, + *, + body: dict[str, Any], + ledger: AnthropicStreamLedger, + tool_argument_alias_buffers: dict[int, str], + ) -> list[str] | None: + schemas = tool_schemas_by_name(self._request) + events: list[str] = [] + for tool_index, state in started_tool_states(ledger): + block = ledger.tool_block_for_tool_index(tool_index) + emitted_prefix = block.content if block is not None else "" + repair_prefix = emitted_prefix + if not repair_prefix and state.name == "Task" and state.task_arg_buffer: + repair_prefix = state.task_arg_buffer + if not repair_prefix and tool_index in tool_argument_alias_buffers: + repair_prefix = tool_argument_alias_buffers[tool_index] + if ( + parse_complete_tool_input(repair_prefix, state.name, schemas) + is not None + ): + if not emitted_prefix and repair_prefix: + events.append(ledger.emit_tool_delta(tool_index, repair_prefix)) + continue + + schema = schemas.get(state.name) + recovery_body = make_tool_repair_body( + body, + tool_name=state.name, + prefix=repair_prefix, + input_schema=schema.input_schema if schema is not None else None, + ) + accepted_suffix: str | None = None + for attempt in range(MIDSTREAM_RECOVERY_ATTEMPTS): + text, _ = await self._collect_recovery_text(recovery_body) + repair = accept_tool_json_repair( + repair_prefix, + text, + tool_name=state.name, + schemas=schemas, + ) + if repair is not None: + accepted_suffix = repair.suffix + trace_event( + stage="provider", + event="provider.recovery.tool_repaired", + source="provider", + provider=self._provider._provider_name, + tool_name=state.name, + attempt=attempt + 1, + ) + break + if accepted_suffix is None: + return None + to_emit = ( + accepted_suffix if emitted_prefix else repair_prefix + accepted_suffix + ) + if to_emit: + events.append(ledger.emit_tool_delta(tool_index, to_emit)) + if not all_emitted_tools_complete(ledger, self._request): + return None + return events + + def _new_ledger(self) -> AnthropicStreamLedger: + return AnthropicStreamLedger( + self._message_id, + self._request.model, + self._input_tokens, + log_raw_events=self._provider._config.log_raw_sse_events, + ) diff --git a/src/free_claude_code/providers/openai_chat/request_policy.py b/src/free_claude_code/providers/openai_chat/request_policy.py new file mode 100644 index 0000000..c509061 --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/request_policy.py @@ -0,0 +1,123 @@ +"""Request-body policy for OpenAI-compatible chat providers.""" + +from collections.abc import Callable, Iterable +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any, Literal + +from loguru import logger + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.core.anthropic import ReasoningReplayMode, build_base_request_body +from free_claude_code.core.anthropic.conversion import OpenAIConversionError +from free_claude_code.core.anthropic.models import MessagesRequest + +MaxTokensField = Literal["max_tokens", "max_completion_tokens"] +OpenAIChatPostprocessor = Callable[[dict[str, Any], MessagesRequest, bool], None] +ExtraBodyValidator = Callable[[dict[str, Any]], None] + + +@dataclass(frozen=True, slots=True) +class OpenAIChatRequestPolicy: + """Provider policy for Anthropic-to-OpenAI chat request conversion.""" + + provider_name: str + include_extra_body: bool = False + extra_body_validator: ExtraBodyValidator | None = None + reject_extra_body_message: str | None = None + default_max_tokens: int | None = None + max_tokens_field: MaxTokensField = "max_tokens" + reasoning_replay: ReasoningReplayMode | None = None + strip_message_names: bool = False + unsupported_body_keys: frozenset[str] = field(default_factory=frozenset) + normalize_n_to_one: bool = False + + +def build_openai_chat_request_body( + request_data: MessagesRequest, + *, + thinking_enabled: bool, + policy: OpenAIChatRequestPolicy, + postprocessors: Iterable[OpenAIChatPostprocessor] = (), +) -> dict[str, Any]: + """Build an OpenAI-compatible chat request body from an Anthropic request.""" + logger.debug( + "{}_REQUEST: conversion start model={} msgs={}", + policy.provider_name, + request_data.model, + len(request_data.messages), + ) + try: + reasoning_replay = policy.reasoning_replay + if reasoning_replay is None: + reasoning_replay = ( + ReasoningReplayMode.REASONING_CONTENT + if thinking_enabled + else ReasoningReplayMode.DISABLED + ) + body = build_base_request_body( + request_data, + default_max_tokens=policy.default_max_tokens, + reasoning_replay=reasoning_replay, + ) + except OpenAIConversionError as exc: + raise InvalidRequestError(str(exc)) from exc + + request_extra = request_data.extra_body + if isinstance(request_extra, dict) and request_extra: + if policy.reject_extra_body_message: + raise InvalidRequestError(policy.reject_extra_body_message) + if policy.include_extra_body: + extra_body = deepcopy(request_extra) + if policy.extra_body_validator is not None: + try: + policy.extra_body_validator(extra_body) + except ValueError as exc: + raise InvalidRequestError(str(exc)) from exc + body["extra_body"] = extra_body + + _apply_common_openai_chat_policy(body, policy) + + for postprocess in postprocessors: + postprocess(body, request_data, thinking_enabled) + + logger.debug( + "{}_REQUEST: conversion done model={} msgs={} tools={}", + policy.provider_name, + body.get("model"), + len(body.get("messages", [])), + len(body.get("tools", [])), + ) + return body + + +def _apply_common_openai_chat_policy( + body: dict[str, Any], policy: OpenAIChatRequestPolicy +) -> None: + if policy.strip_message_names: + _strip_message_names(body.get("messages")) + + for key in policy.unsupported_body_keys: + body.pop(key, None) + + if policy.max_tokens_field == "max_completion_tokens": + _normalize_max_completion_tokens(body) + + if policy.normalize_n_to_one and body.get("n") is not None: + body["n"] = 1 + + +def _strip_message_names(messages: Any) -> None: + if not isinstance(messages, list): + return + for message in messages: + if isinstance(message, dict): + message.pop("name", None) + + +def _normalize_max_completion_tokens(body: dict[str, Any]) -> None: + if "max_completion_tokens" in body: + body.pop("max_tokens", None) + return + if "max_tokens" in body and body["max_tokens"] is not None: + body["max_completion_tokens"] = body.pop("max_tokens") diff --git a/src/free_claude_code/providers/openai_chat/tool_calls.py b/src/free_claude_code/providers/openai_chat/tool_calls.py new file mode 100644 index 0000000..7c3d7e3 --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/tool_calls.py @@ -0,0 +1,285 @@ +"""OpenAI-chat tool-call assembly helpers.""" + +import json +import uuid +from collections.abc import Callable, Iterator +from typing import Any + +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.anthropic.streaming import ( + AnthropicStreamLedger, + tool_schemas_by_name, +) + +RecordToolExtraContent = Callable[[str, dict[str, Any]], None] + + +def iter_heuristic_tool_use_sse( + ledger: AnthropicStreamLedger, tool_use: dict[str, Any] +) -> Iterator[str]: + """Emit SSE for one heuristic tool_use block.""" + if tool_use.get("name") == "Task" and isinstance(tool_use.get("input"), dict): + task_input = tool_use["input"] + if task_input.get("run_in_background") is not False: + task_input["run_in_background"] = False + yield from ledger.close_content_blocks() + block_idx = ledger.blocks.allocate_index() + yield ledger.content_block_start( + block_idx, + "tool_use", + id=tool_use["id"], + name=tool_use["name"], + ) + yield ledger.content_block_delta( + block_idx, + "input_json_delta", + json.dumps(tool_use["input"]), + ) + yield ledger.content_block_stop(block_idx) + + +def tool_call_extra_content(tool_call: Any) -> dict[str, Any] | None: + """Return provider-specific extra tool-call metadata from OpenAI objects.""" + if isinstance(tool_call, dict): + value = tool_call.get("extra_content") + return value if isinstance(value, dict) else None + + value = getattr(tool_call, "extra_content", None) + if isinstance(value, dict): + return value + + model_extra = getattr(tool_call, "model_extra", None) + if isinstance(model_extra, dict): + value = model_extra.get("extra_content") + if isinstance(value, dict): + return value + + pydantic_extra = getattr(tool_call, "__pydantic_extra__", None) + if isinstance(pydantic_extra, dict): + value = pydantic_extra.get("extra_content") + if isinstance(value, dict): + return value + + return None + + +def has_committed_sse_output(ledger: AnthropicStreamLedger) -> bool: + """Return whether any assistant content escaped the builder.""" + return ( + ledger.blocks.text_index != -1 + or ledger.blocks.thinking_index != -1 + or ledger.has_emitted_tool_block() + ) + + +def started_tool_states(ledger: AnthropicStreamLedger) -> list[tuple[int, Any]]: + """Return started tool states in stream order.""" + return [ + (tool_index, state) + for tool_index, state in ledger.blocks.tool_states.items() + if state.started + ] + + +def all_emitted_tools_complete( + ledger: AnthropicStreamLedger, request: MessagesRequest +) -> bool: + """Return whether every emitted tool block has schema-valid input.""" + return ledger.can_salvage_tool_use(tool_schemas_by_name(request)) + + +class OpenAIToolCallAssembler: + """Assemble OpenAI tool-call deltas into Anthropic SSE tool blocks.""" + + def __init__( + self, *, record_extra_content: RecordToolExtraContent | None = None + ) -> None: + self._record_extra_content = record_extra_content + + def process_tool_call( + self, + tc: dict[str, Any], + ledger: AnthropicStreamLedger, + *, + tool_argument_aliases: dict[str, dict[str, str]] | None = None, + tool_argument_alias_buffers: dict[int, str] | None = None, + ) -> Iterator[str]: + """Process a single tool-call delta and yield Anthropic SSE events.""" + raw_index = tc.get("index", 0) + tc_index = raw_index if isinstance(raw_index, int) else 0 + if tc_index < 0: + tc_index = len(ledger.blocks.tool_states) + + fn_delta = tc.get("function", {}) + incoming_name = fn_delta.get("name") + arguments = fn_delta.get("arguments", "") or "" + + if tc.get("id") is not None: + ledger.blocks.set_stream_tool_id(tc_index, tc.get("id")) + + raw_extra_content = tc.get("extra_content") + extra_content = ( + raw_extra_content + if isinstance(raw_extra_content, dict) and raw_extra_content + else None + ) + if extra_content: + ledger.blocks.set_tool_extra_content(tc_index, extra_content) + + if incoming_name is not None: + ledger.blocks.register_tool_name(tc_index, incoming_name) + + state = ledger.blocks.tool_states.get(tc_index) + resolved_id = (state.tool_id if state and state.tool_id else None) or tc.get( + "id" + ) + resolved_name = (state.name if state else "") or "" + + if not state or not state.started: + name_ok = bool((resolved_name or "").strip()) + if name_ok: + tool_id = str(resolved_id) if resolved_id else f"tool_{uuid.uuid4()}" + display_name = (resolved_name or "").strip() or "tool_call" + start_extra_content = state.extra_content if state else extra_content + if start_extra_content: + self._record_tool_call_extra_content(tool_id, start_extra_content) + yield ledger.start_tool_block( + tc_index, + tool_id, + display_name, + extra_content=start_extra_content, + ) + state = ledger.blocks.tool_states[tc_index] + if state.pre_start_args: + pre = state.pre_start_args + state.pre_start_args = "" + yield from self._emit_tool_arg_delta( + ledger, + tc_index, + pre, + tool_argument_aliases=tool_argument_aliases, + tool_argument_alias_buffers=tool_argument_alias_buffers, + ) + + state = ledger.blocks.tool_states.get(tc_index) + if state is not None and state.tool_id and extra_content: + self._record_tool_call_extra_content(state.tool_id, extra_content) + if not arguments: + return + if state is None or not state.started: + state = ledger.blocks.ensure_tool_state(tc_index) + if not (resolved_name or "").strip(): + state.pre_start_args += arguments + return + + yield from self._emit_tool_arg_delta( + ledger, + tc_index, + arguments, + tool_argument_aliases=tool_argument_aliases, + tool_argument_alias_buffers=tool_argument_alias_buffers, + ) + + def flush_task_arg_buffers(self, ledger: AnthropicStreamLedger) -> Iterator[str]: + """Emit buffered Task args as a single JSON delta.""" + for tool_index, out in ledger.blocks.flush_task_arg_buffers(): + yield ledger.emit_tool_delta(tool_index, out) + + def flush_tool_argument_alias_buffers( + self, + ledger: AnthropicStreamLedger, + tool_argument_aliases: dict[str, dict[str, str]], + tool_argument_alias_buffers: dict[int, str], + ) -> Iterator[str]: + """Emit remaining aliased args without losing malformed JSON.""" + for tool_index, buffered_args in list(tool_argument_alias_buffers.items()): + if not buffered_args: + tool_argument_alias_buffers.pop(tool_index, None) + continue + state = ledger.blocks.tool_states.get(tool_index) + if state is None or state.name == "Task": + continue + aliases = tool_argument_aliases.get(state.name, {}) + if not aliases: + continue + restored = self._restore_aliased_tool_arguments(buffered_args, aliases) + yield ledger.emit_tool_delta( + tool_index, + restored if restored is not None else buffered_args, + ) + tool_argument_alias_buffers.pop(tool_index, None) + + def _emit_tool_arg_delta( + self, + ledger: AnthropicStreamLedger, + tc_index: int, + args: str, + *, + tool_argument_aliases: dict[str, dict[str, str]] | None = None, + tool_argument_alias_buffers: dict[int, str] | None = None, + ) -> Iterator[str]: + """Emit one argument fragment for a started tool block.""" + if not args: + return + state = ledger.blocks.tool_states.get(tc_index) + if state is None: + return + if state.name == "Task": + parsed = ledger.blocks.buffer_task_args(tc_index, args) + if parsed is not None: + yield ledger.emit_tool_delta(tc_index, json.dumps(parsed)) + return + aliases = ( + tool_argument_aliases.get(state.name, {}) if tool_argument_aliases else {} + ) + if aliases: + if tool_argument_alias_buffers is None: + restored = self._restore_aliased_tool_arguments(args, aliases) + if restored is not None: + yield ledger.emit_tool_delta(tc_index, restored) + return + + buffered_args = tool_argument_alias_buffers.get(tc_index, "") + args + restored = self._restore_aliased_tool_arguments(buffered_args, aliases) + if restored is None: + tool_argument_alias_buffers[tc_index] = buffered_args + return + tool_argument_alias_buffers.pop(tc_index, None) + yield ledger.emit_tool_delta(tc_index, restored) + return + yield ledger.emit_tool_delta(tc_index, args) + + def _restore_aliased_tool_arguments( + self, argument_json: str, aliases: dict[str, str] + ) -> str | None: + try: + parsed = json.loads(argument_json) + except json.JSONDecodeError: + return None + if not isinstance(parsed, dict): + return argument_json + restored = self._restore_aliased_tool_argument_value(parsed, aliases) + return json.dumps(restored) + + def _restore_aliased_tool_argument_value( + self, value: Any, aliases: dict[str, str] + ) -> Any: + if isinstance(value, dict): + return { + aliases.get(key, key): self._restore_aliased_tool_argument_value( + item, aliases + ) + for key, item in value.items() + } + if isinstance(value, list): + return [ + self._restore_aliased_tool_argument_value(item, aliases) + for item in value + ] + return value + + def _record_tool_call_extra_content( + self, tool_call_id: str, extra_content: dict[str, Any] + ) -> None: + if self._record_extra_content is not None: + self._record_extra_content(tool_call_id, extra_content) diff --git a/src/free_claude_code/providers/openai_chat/usage.py b/src/free_claude_code/providers/openai_chat/usage.py new file mode 100644 index 0000000..4be8dff --- /dev/null +++ b/src/free_claude_code/providers/openai_chat/usage.py @@ -0,0 +1,98 @@ +"""OpenAI-chat streamed usage request and extraction helpers.""" + +import json +from collections.abc import Mapping +from typing import Any + +import openai + +_USAGE_OPTION_KEYS = ("stream_options", "include_usage") +_USAGE_REJECTION_WORDS = ( + "unsupported", + "not supported", + "unknown", + "unrecognized", + "unexpected", + "invalid", + "extra", + "forbidden", + "not permitted", +) + + +def request_stream_usage(body: dict[str, Any]) -> None: + """Ask an OpenAI-compatible streaming endpoint for its final usage chunk.""" + stream_options = body.get("stream_options") + if stream_options is None: + body["stream_options"] = {"include_usage": True} + return + if isinstance(stream_options, dict): + stream_options["include_usage"] = True + + +def clone_without_stream_usage(body: dict[str, Any]) -> dict[str, Any] | None: + """Return a clone with only ``include_usage`` removed from stream options.""" + stream_options = body.get("stream_options") + if not isinstance(stream_options, dict): + return None + if "include_usage" not in stream_options: + return None + + retry_body = dict(body) + retry_stream_options = dict(stream_options) + retry_stream_options.pop("include_usage", None) + if retry_stream_options: + retry_body["stream_options"] = retry_stream_options + else: + retry_body.pop("stream_options", None) + return retry_body + + +def is_stream_usage_rejection(error: Exception) -> bool: + """Return whether upstream rejected the optional streamed-usage request.""" + if not _is_bad_request_like(error): + return False + text = _error_text(error) + if not any(key in text for key in _USAGE_OPTION_KEYS): + return False + return any(word in text for word in _USAGE_REJECTION_WORDS) + + +def usage_int(usage_info: Any, key: str) -> int | None: + """Extract an integer usage field from OpenAI SDK objects or plain dicts.""" + if usage_info is None: + return None + if isinstance(usage_info, Mapping): + value = usage_info.get(key) + else: + value = getattr(usage_info, key, None) + if value is None: + extra = getattr(usage_info, "model_extra", None) + if isinstance(extra, Mapping): + value = extra.get(key) + return value if isinstance(value, int) and not isinstance(value, bool) else None + + +def _is_bad_request_like(error: Exception) -> bool: + if isinstance(error, openai.BadRequestError): + return True + status = getattr(error, "status_code", None) + if status is None: + response = getattr(error, "response", None) + status = ( + getattr(response, "status_code", None) if response is not None else None + ) + return status in (400, 422) + + +def _error_text(error: Exception) -> str: + parts = [str(error)] + body = getattr(error, "body", None) + if body is not None: + parts.append(json.dumps(body, default=str)) + response = getattr(error, "response", None) + if response is not None: + text = getattr(response, "text", None) + if isinstance(text, str) and text: + parts.append(text) + return " ".join(parts).lower() diff --git a/src/free_claude_code/providers/rate_limit.py b/src/free_claude_code/providers/rate_limit.py new file mode 100644 index 0000000..5ec7337 --- /dev/null +++ b/src/free_claude_code/providers/rate_limit.py @@ -0,0 +1,233 @@ +"""Provider-owned upstream rate limiting and retry policy.""" + +import asyncio +import random +import time +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from typing import Any, TypeVar + +from loguru import logger + +from free_claude_code.core.rate_limit import StrictSlidingWindowLimiter +from free_claude_code.core.trace import trace_event +from free_claude_code.providers.failure_policy import ( + ProviderFailureOverride, + retryable_upstream_status, + retryable_upstream_transport_error, +) + +T = TypeVar("T") + +UPSTREAM_TRANSIENT_TOTAL_ATTEMPTS = 5 +DEFAULT_UPSTREAM_MAX_RETRIES = UPSTREAM_TRANSIENT_TOTAL_ATTEMPTS - 1 + + +class ProviderRateLimiter: + """ + Rate limiter owned by one provider instance. + + Blocks that provider's requests when a rate-limit error is encountered + (reactive) and throttles its requests with a strict rolling window + (proactive). + + Optionally enforces a max_concurrency cap: at most N provider streams + may be open simultaneously, independent of the sliding window. + + Proactive limits - throttles requests to stay within API limits. + Reactive limits - pauses all requests when a 429 or 5xx retry backoff is active. + Concurrency limit - caps simultaneously open streams. + """ + + def __init__( + self, + rate_limit: int = 40, + rate_window: float = 60.0, + max_concurrency: int = 5, + ): + if rate_limit <= 0: + raise ValueError("rate_limit must be > 0") + if rate_window <= 0: + raise ValueError("rate_window must be > 0") + if max_concurrency <= 0: + raise ValueError("max_concurrency must be > 0") + + self._rate_limit = rate_limit + self._rate_window = float(rate_window) + self._max_concurrency = max_concurrency + self._proactive_limiter = StrictSlidingWindowLimiter( + self._rate_limit, self._rate_window + ) + self._blocked_until: float = 0 + self._concurrency_sem = asyncio.Semaphore(max_concurrency) + logger.info( + "ProviderRateLimiter initialized " + f"({rate_limit} req / {rate_window}s, max_concurrency={max_concurrency})" + ) + + async def wait_if_blocked(self) -> bool: + """ + Wait if currently rate limited or throttle to meet quota. + + Returns: + True if was reactively blocked and waited, False otherwise. + """ + # A reactive deadline can be installed or extended while this task waits + # for proactive capacity. Commit the proactive timestamp only if that + # deadline is still clear, so retries neither burst nor consume unused quota. + waited_reactively = False + while True: + waited_reactively = ( + await self._wait_for_reactive_block() or waited_reactively + ) + if await self._proactive_limiter.acquire_if(lambda: not self.is_blocked()): + return waited_reactively + + async def _wait_for_reactive_block(self) -> bool: + waited = False + while (wait_time := self.remaining_wait()) > 0: + logger.warning( + "Provider rate limit active (reactive), waiting {:.1f}s...", + wait_time, + ) + await asyncio.sleep(wait_time) + waited = True + return waited + + def extend_reactive_block(self, seconds: float) -> None: + """ + Extend this provider's reactive block by at least ``seconds`` from now. + + Args: + seconds: Positive minimum duration for the resulting block. + """ + if seconds <= 0: + raise ValueError("reactive block duration must be > 0") + now = time.monotonic() + self._blocked_until = max(self._blocked_until, now + seconds) + logger.warning( + "Provider rate limit set for {:.1f}s (reactive)", + max(0.0, self._blocked_until - now), + ) + + def is_blocked(self) -> bool: + """Check if currently reactively blocked.""" + return time.monotonic() < self._blocked_until + + def remaining_wait(self) -> float: + """Get remaining reactive wait time in seconds.""" + return max(0.0, self._blocked_until - time.monotonic()) + + @asynccontextmanager + async def concurrency_slot(self) -> AsyncIterator[None]: + """Async context manager that holds one concurrency slot for a stream. + + Blocks until a slot is available (controlled by max_concurrency). + """ + await self._concurrency_sem.acquire() + try: + yield + finally: + self._concurrency_sem.release() + + async def execute_with_retry( + self, + fn: Callable[..., Any], + *args: Any, + provider_failure_override: ProviderFailureOverride | None = None, + max_retries: int = DEFAULT_UPSTREAM_MAX_RETRIES, + base_delay: float = 2.0, + max_delay: float = 60.0, + jitter: float = 1.0, + **kwargs: Any, + ) -> Any: + """Execute an async callable with rate limiting and retry on transient limits. + + Waits for the proactive limiter before each attempt. On ``429`` (rate limit) + or upstream ``5xx`` server errors, applies exponential backoff with jitter + and sets the reactive block before retrying. Pre-response transport errors + use the same attempt budget and backoff schedule without setting the + reactive provider block. + + Args: + fn: Async callable to execute. + provider_failure_override: Optional provider-specific semantic + classifier applied before shared retry qualification. + max_retries: Maximum number of retry attempts after the first failure. + base_delay: Base delay in seconds for exponential backoff. + max_delay: Maximum delay cap in seconds. + jitter: Maximum random jitter in seconds added to each delay. + + Returns: + The result of the callable. + + Raises: + The last exception if all retries are exhausted. + """ + last_exc: Exception | None = None + total_attempts = 1 + max_retries + + for attempt in range(total_attempts): + await self.wait_if_blocked() + + try: + return await fn(*args, **kwargs) + except Exception as e: + effective_error = ( + provider_failure_override(e) + if provider_failure_override is not None + else None + ) + if effective_error is None: + effective_error = e + status = retryable_upstream_status(effective_error) + transport_error = status is None and retryable_upstream_transport_error( + effective_error + ) + if status is None and not transport_error: + raise + + if status is None: + label = f"Provider transport error ({type(e).__name__})" + else: + label = ( + "Rate limited (429)" + if status == 429 + else f"Upstream server error ({status})" + ) + last_exc = e + if attempt >= max_retries: + logger.warning( + "{} retry exhausted after {} retries (attempts={})", + label, + max_retries, + total_attempts, + ) + break + + delay = min(base_delay * (2**attempt), max_delay) + delay += random.uniform(0, jitter) + attempt_no = attempt + 1 + logger.warning( + "{}, attempt {}/{}. Retrying in {:.1f}s...", + label, + attempt_no, + total_attempts, + delay, + ) + trace_event( + stage="provider", + event="provider.retry.scheduled", + source="provider", + status_code=status, + exc_type=type(e).__name__, + attempt=attempt_no, + max_attempts=total_attempts, + delay_s=round(delay, 3), + ) + if status is not None: + self.extend_reactive_block(delay) + await asyncio.sleep(delay) + + assert last_exc is not None + raise last_exc diff --git a/src/free_claude_code/providers/runtime/__init__.py b/src/free_claude_code/providers/runtime/__init__.py new file mode 100644 index 0000000..9470b00 --- /dev/null +++ b/src/free_claude_code/providers/runtime/__init__.py @@ -0,0 +1,11 @@ +"""App-scoped provider runtime facade.""" + +from .config import build_provider_config +from .factory import create_provider +from .runtime import ProviderRuntime + +__all__ = [ + "ProviderRuntime", + "build_provider_config", + "create_provider", +] diff --git a/src/free_claude_code/providers/runtime/config.py b/src/free_claude_code/providers/runtime/config.py new file mode 100644 index 0000000..c0c28b2 --- /dev/null +++ b/src/free_claude_code/providers/runtime/config.py @@ -0,0 +1,68 @@ +"""Provider configuration construction from neutral catalog metadata.""" + +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.config.provider_catalog import ProviderDescriptor +from free_claude_code.config.settings import Settings +from free_claude_code.providers.base import ProviderConfig + + +def string_setting(settings: Settings, attr_name: str | None, default: str = "") -> str: + """Return a string-valued settings attribute, ignoring non-string mocks.""" + if attr_name is None: + return default + value = getattr(settings, attr_name, default) + return value if isinstance(value, str) else default + + +def provider_credential(descriptor: ProviderDescriptor, settings: Settings) -> str: + """Return the configured credential for a provider descriptor.""" + if descriptor.static_credential is not None: + return descriptor.static_credential + if descriptor.credential_attr: + return string_setting(settings, descriptor.credential_attr) + return "" + + +def require_provider_credential( + descriptor: ProviderDescriptor, credential: str +) -> None: + """Raise a user-facing configuration error when a required key is missing.""" + if descriptor.credential_env is None: + return + if credential and credential.strip(): + return + message = f"{descriptor.credential_env} is not set. Add it to your .env file." + if descriptor.credential_url: + message = f"{message} Get a key at {descriptor.credential_url}" + raise ApplicationUnavailableError(message) + + +def build_provider_config( + descriptor: ProviderDescriptor, settings: Settings +) -> ProviderConfig: + """Build shared provider configuration for one provider descriptor.""" + credential = provider_credential(descriptor, settings) + require_provider_credential(descriptor, credential) + base_url = string_setting( + settings, descriptor.base_url_attr, descriptor.default_base_url or "" + ) + resolved_base_url = base_url or descriptor.default_base_url + if not resolved_base_url: + raise AssertionError( + f"Provider {descriptor.provider_id!r} has no configured base URL." + ) + proxy = string_setting(settings, descriptor.proxy_attr) + return ProviderConfig( + api_key=credential, + base_url=resolved_base_url, + rate_limit=settings.provider_rate_limit, + rate_window=settings.provider_rate_window, + max_concurrency=settings.provider_max_concurrency, + http_read_timeout=settings.http_read_timeout, + http_write_timeout=settings.http_write_timeout, + http_connect_timeout=settings.http_connect_timeout, + enable_thinking=settings.enable_model_thinking, + proxy=proxy, + log_raw_sse_events=settings.log_raw_sse_events, + log_api_error_tracebacks=settings.log_api_error_tracebacks, + ) diff --git a/src/free_claude_code/providers/runtime/discovery.py b/src/free_claude_code/providers/runtime/discovery.py new file mode 100644 index 0000000..d9bea0e --- /dev/null +++ b/src/free_claude_code/providers/runtime/discovery.py @@ -0,0 +1,99 @@ +"""Provider model-list discovery and background refresh.""" + +import asyncio +from collections.abc import Callable + +from loguru import logger + +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.model_refs import configured_chat_model_refs +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.config.settings import Settings +from free_claude_code.providers.base import BaseProvider + +from .config import provider_credential +from .model_cache import ProviderModelCache +from .validation import provider_query_failure_reason + +ProviderResolver = Callable[[str], BaseProvider] + + +def referenced_provider_ids(settings: Settings) -> frozenset[str]: + """Return provider ids referenced by configured chat model refs.""" + return frozenset(ref.provider_id for ref in configured_chat_model_refs(settings)) + + +def model_list_provider_ids_for_settings(settings: Settings) -> tuple[str, ...]: + """Return providers worth discovering for this process configuration.""" + referenced_ids = referenced_provider_ids(settings) + provider_ids: list[str] = [] + for provider_id, descriptor in PROVIDER_CATALOG.items(): + if descriptor.local: + if provider_id in referenced_ids: + provider_ids.append(provider_id) + continue + if ( + descriptor.credential_env is not None + and provider_credential(descriptor, settings).strip() + ): + provider_ids.append(provider_id) + return tuple(provider_ids) + + +class ProviderModelDiscovery: + """Refresh provider model-list metadata for one provider runtime.""" + + def __init__( + self, + settings: Settings, + provider_resolver: ProviderResolver, + model_cache: ProviderModelCache, + ) -> None: + self._settings = settings + self._provider_resolver = provider_resolver + self._model_cache = model_cache + + async def refresh_model_list_cache(self, *, only_missing: bool = False) -> None: + """Best-effort refresh of model lists for usable providers.""" + provider_ids = model_list_provider_ids_for_settings(self._settings) + if only_missing: + provider_ids = tuple( + provider_id + for provider_id in provider_ids + if not self._model_cache.has_provider(provider_id) + ) + await self._refresh_model_infos(provider_ids) + + async def _refresh_model_infos(self, provider_ids: tuple[str, ...]) -> None: + tasks: dict[str, asyncio.Task[frozenset[ProviderModelInfo]]] = {} + for provider_id in provider_ids: + try: + provider = self._provider_resolver(provider_id) + except Exception as exc: + self._log_discovery_failure(provider_id, exc) + continue + tasks[provider_id] = asyncio.create_task(provider.list_model_infos()) + + if not tasks: + return + + results = await asyncio.gather(*tasks.values(), return_exceptions=True) + for (provider_id, _task), result in zip(tasks.items(), results, strict=True): + if isinstance(result, BaseException): + if isinstance(result, asyncio.CancelledError): + raise result + self._log_discovery_failure(provider_id, result) + continue + self._model_cache.cache_model_infos(provider_id, result) + logger.info( + "Provider model discovery cached: provider={} models={}", + provider_id, + len(result), + ) + + def _log_discovery_failure(self, provider_id: str, exc: BaseException) -> None: + logger.warning( + "Provider model discovery skipped: provider={} reason={}", + provider_id, + provider_query_failure_reason(exc, self._settings), + ) diff --git a/src/free_claude_code/providers/runtime/factory.py b/src/free_claude_code/providers/runtime/factory.py new file mode 100644 index 0000000..78e7984 --- /dev/null +++ b/src/free_claude_code/providers/runtime/factory.py @@ -0,0 +1,148 @@ +"""Provider construction from declarative profiles and exceptional adapters.""" + +from collections.abc import Callable + +from free_claude_code.application.errors import UnknownProviderError +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.config.settings import Settings +from free_claude_code.providers.base import BaseProvider, ProviderConfig +from free_claude_code.providers.openai_chat import ( + OPENAI_CHAT_PROFILES, + create_openai_chat_provider, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + +from .config import build_provider_config + +ProviderFactory = Callable[ + [ProviderConfig, Settings, ProviderRateLimiter], BaseProvider +] + + +def _create_nvidia_nim( + config: ProviderConfig, + settings: Settings, + rate_limiter: ProviderRateLimiter, +) -> BaseProvider: + from free_claude_code.providers.nvidia_nim import NvidiaNimProvider + + return NvidiaNimProvider( + config, + nim_settings=settings.nim, + rate_limiter=rate_limiter, + ) + + +def _create_open_router( + config: ProviderConfig, + _settings: Settings, + rate_limiter: ProviderRateLimiter, +) -> BaseProvider: + from free_claude_code.providers.open_router import OpenRouterProvider + + return OpenRouterProvider(config, rate_limiter=rate_limiter) + + +def _create_mistral( + config: ProviderConfig, + _settings: Settings, + rate_limiter: ProviderRateLimiter, +) -> BaseProvider: + from free_claude_code.providers.mistral import MistralProvider + + return MistralProvider(config, rate_limiter=rate_limiter) + + +def _create_deepseek( + config: ProviderConfig, + _settings: Settings, + rate_limiter: ProviderRateLimiter, +) -> BaseProvider: + from free_claude_code.providers.deepseek import DeepSeekProvider + + return DeepSeekProvider(config, rate_limiter=rate_limiter) + + +def _create_lmstudio( + config: ProviderConfig, + _settings: Settings, + rate_limiter: ProviderRateLimiter, +) -> BaseProvider: + from free_claude_code.providers.lmstudio import LMStudioProvider + + return LMStudioProvider(config, rate_limiter=rate_limiter) + + +def _create_cloudflare( + config: ProviderConfig, + settings: Settings, + rate_limiter: ProviderRateLimiter, +) -> BaseProvider: + from free_claude_code.providers.cloudflare import CloudflareProvider + + return CloudflareProvider( + config, + account_id=settings.cloudflare_account_id, + rate_limiter=rate_limiter, + ) + + +def _create_gemini( + config: ProviderConfig, + _settings: Settings, + rate_limiter: ProviderRateLimiter, +) -> BaseProvider: + from free_claude_code.providers.gemini import GeminiProvider + + return GeminiProvider(config, rate_limiter=rate_limiter) + + +def _create_github_models( + config: ProviderConfig, + _settings: Settings, + rate_limiter: ProviderRateLimiter, +) -> BaseProvider: + from free_claude_code.providers.github_models import GitHubModelsProvider + + return GitHubModelsProvider(config, rate_limiter=rate_limiter) + + +_SPECIAL_PROVIDER_FACTORIES: dict[str, ProviderFactory] = { + "nvidia_nim": _create_nvidia_nim, + "open_router": _create_open_router, + "mistral": _create_mistral, + "deepseek": _create_deepseek, + "lmstudio": _create_lmstudio, + "cloudflare": _create_cloudflare, + "gemini": _create_gemini, + "github_models": _create_github_models, +} + +_profiled_ids = set(OPENAI_CHAT_PROFILES) +_special_ids = set(_SPECIAL_PROVIDER_FACTORIES) +if _profiled_ids & _special_ids or _profiled_ids | _special_ids != set( + PROVIDER_CATALOG +): + raise AssertionError( + "Every provider must have exactly one construction owner: " + f"profiles={_profiled_ids!r} special={_special_ids!r} " + f"catalog={set(PROVIDER_CATALOG)!r}" + ) + + +def create_provider(provider_id: str, settings: Settings) -> BaseProvider: + """Create a provider instance for a supported provider id.""" + descriptor = PROVIDER_CATALOG.get(provider_id) + if descriptor is None: + raise UnknownProviderError.for_provider(provider_id, PROVIDER_CATALOG) + + config = build_provider_config(descriptor, settings) + rate_limiter = ProviderRateLimiter( + rate_limit=config.rate_limit or 40, + rate_window=config.rate_window or 60.0, + max_concurrency=config.max_concurrency, + ) + factory = _SPECIAL_PROVIDER_FACTORIES.get(provider_id) + if factory is not None: + return factory(config, settings, rate_limiter) + return create_openai_chat_provider(provider_id, config, rate_limiter) diff --git a/src/free_claude_code/providers/runtime/model_cache.py b/src/free_claude_code/providers/runtime/model_cache.py new file mode 100644 index 0000000..22f4bee --- /dev/null +++ b/src/free_claude_code/providers/runtime/model_cache.py @@ -0,0 +1,71 @@ +"""Provider model-list metadata cache.""" + +from collections.abc import Iterable + +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.provider_catalog import SUPPORTED_PROVIDER_IDS +from free_claude_code.providers.model_listing import model_infos_from_ids + + +class ProviderModelCache: + """Store provider model metadata for instant model-list responses.""" + + def __init__(self) -> None: + self._model_infos_by_provider: dict[str, dict[str, ProviderModelInfo]] = {} + + def cache_model_ids(self, provider_id: str, model_ids: Iterable[str]) -> None: + """Store raw provider model ids with unknown capability metadata.""" + self.cache_model_infos(provider_id, model_infos_from_ids(model_ids)) + + def cache_model_infos( + self, provider_id: str, model_infos: Iterable[ProviderModelInfo] + ) -> None: + """Store provider model metadata by raw provider model id.""" + clean_infos = { + info.model_id: info for info in model_infos if info.model_id.strip() + } + self._model_infos_by_provider[provider_id] = clean_infos + + def cached_model_ids(self) -> dict[str, frozenset[str]]: + """Return cached raw provider model ids by provider.""" + return { + provider_id: frozenset(infos) + for provider_id, infos in self._model_infos_by_provider.items() + } + + def has_provider(self, provider_id: str) -> bool: + """Return whether this provider has any cached model-list result.""" + return provider_id in self._model_infos_by_provider + + def cached_model_supports_thinking( + self, provider_id: str, model_id: str + ) -> bool | None: + """Return cached thinking support when a provider exposes it.""" + info = self._model_infos_by_provider.get(provider_id, {}).get(model_id) + if info is None: + return None + return info.supports_thinking + + def cached_prefixed_model_refs(self) -> tuple[str, ...]: + """Return cached provider models in user-selectable ``provider/model`` form.""" + return tuple(info.model_id for info in self.cached_prefixed_model_infos()) + + def cached_prefixed_model_infos(self) -> tuple[ProviderModelInfo, ...]: + """Return cached provider models with user-selectable prefixed ids.""" + infos: list[ProviderModelInfo] = [] + for provider_id in SUPPORTED_PROVIDER_IDS: + provider_infos = self._model_infos_by_provider.get(provider_id, {}) + infos.extend( + ProviderModelInfo( + model_id=f"{provider_id}/{info.model_id}", + supports_thinking=info.supports_thinking, + ) + for info in sorted( + provider_infos.values(), key=lambda item: item.model_id + ) + ) + return tuple(infos) + + def clear(self) -> None: + """Clear all cached model metadata.""" + self._model_infos_by_provider.clear() diff --git a/src/free_claude_code/providers/runtime/runtime.py b/src/free_claude_code/providers/runtime/runtime.py new file mode 100644 index 0000000..1974e80 --- /dev/null +++ b/src/free_claude_code/providers/runtime/runtime.py @@ -0,0 +1,48 @@ +"""One closable generation of lazily constructed provider clients.""" + +import asyncio +from collections.abc import MutableMapping + +from free_claude_code.config.settings import Settings +from free_claude_code.providers.base import BaseProvider + +from .factory import create_provider + + +class ProviderRuntime: + """Own provider instances for one immutable settings snapshot.""" + + def __init__( + self, + settings: Settings, + providers: MutableMapping[str, BaseProvider] | None = None, + ) -> None: + self.settings = settings + self._providers = providers if providers is not None else {} + + def is_cached(self, provider_id: str) -> bool: + """Return whether a provider for this id is already cached.""" + return provider_id in self._providers + + def resolve_provider(self, provider_id: str) -> BaseProvider: + """Return an existing provider or create it lazily.""" + if provider_id not in self._providers: + self._providers[provider_id] = create_provider(provider_id, self.settings) + return self._providers[provider_id] + + async def cleanup(self) -> None: + """Release every provider client constructed by this generation.""" + errors: list[Exception] = [] + for provider_id, provider in list(self._providers.items()): + try: + await provider.cleanup() + except asyncio.CancelledError: + raise + except Exception as exc: + errors.append(exc) + else: + self._providers.pop(provider_id, None) + if len(errors) == 1: + raise errors[0] + if len(errors) > 1: + raise ExceptionGroup("One or more provider cleanups failed", errors) diff --git a/src/free_claude_code/providers/runtime/validation.py b/src/free_claude_code/providers/runtime/validation.py new file mode 100644 index 0000000..1da0082 --- /dev/null +++ b/src/free_claude_code/providers/runtime/validation.py @@ -0,0 +1,122 @@ +"""Configured provider model validation.""" + +import asyncio +from collections import defaultdict +from collections.abc import Callable + +import httpx +from loguru import logger + +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.model_refs import ( + ConfiguredChatModelRef, + configured_chat_model_refs, +) +from free_claude_code.config.settings import Settings +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.providers.base import BaseProvider +from free_claude_code.providers.model_listing import ModelListResponseError + +from .model_cache import ProviderModelCache + +ProviderResolver = Callable[[str], BaseProvider] + + +def provider_query_failure_reason(exc: BaseException, settings: Settings) -> str: + """Return a concise model-list query failure reason for user-facing logs.""" + if isinstance(exc, ModelListResponseError): + return f"malformed model-list response: {exc.message}" + if isinstance(exc, httpx.HTTPStatusError): + return f"query failure: HTTP {exc.response.status_code}" + if isinstance(exc, ApplicationUnavailableError): + return f"query failure: {exc.message}" + if isinstance(exc, ExecutionFailure) and settings.log_api_error_tracebacks: + return f"query failure: {exc.message}" + return f"query failure: {type(exc).__name__}" + + +class ConfiguredModelValidator: + """Validate configured provider/model refs against upstream model lists.""" + + def __init__( + self, + settings: Settings, + provider_resolver: ProviderResolver, + model_cache: ProviderModelCache, + ) -> None: + self._settings = settings + self._provider_resolver = provider_resolver + self._model_cache = model_cache + + async def validate_configured_models(self) -> None: + """Fail unless every configured chat model exists upstream.""" + refs = configured_chat_model_refs(self._settings) + refs_by_provider: dict[str, list[ConfiguredChatModelRef]] = defaultdict(list) + for ref in refs: + refs_by_provider[ref.provider_id].append(ref) + + failures: list[str] = [] + tasks: dict[str, asyncio.Task[frozenset[ProviderModelInfo]]] = {} + for provider_id, provider_refs in refs_by_provider.items(): + try: + provider = self._provider_resolver(provider_id) + except Exception as exc: + failures.extend( + self._format_provider_query_failures(provider_refs, exc) + ) + continue + tasks[provider_id] = asyncio.create_task(provider.list_model_infos()) + + if tasks: + results = await asyncio.gather(*tasks.values(), return_exceptions=True) + for (provider_id, _task), result in zip( + tasks.items(), results, strict=True + ): + provider_refs = refs_by_provider[provider_id] + if isinstance(result, BaseException): + if isinstance(result, asyncio.CancelledError): + raise result + failures.extend( + self._format_provider_query_failures(provider_refs, result) + ) + continue + self._model_cache.cache_model_infos(provider_id, result) + model_ids = self._model_cache.cached_model_ids()[provider_id] + failures.extend( + self._format_missing_model_failure(ref) + for ref in provider_refs + if ref.model_id not in model_ids + ) + + if failures: + message = "Configured model validation failed:\n" + "\n".join( + f"- {failure}" for failure in failures + ) + raise ApplicationUnavailableError(message) + + logger.info( + "Configured provider models validated: models={} providers={}", + len(refs), + len(refs_by_provider), + ) + + def _format_provider_query_failures( + self, + refs: list[ConfiguredChatModelRef], + exc: BaseException, + ) -> list[str]: + reason = provider_query_failure_reason(exc, self._settings) + return [self._format_model_validation_failure(ref, reason) for ref in refs] + + def _format_missing_model_failure(self, ref: ConfiguredChatModelRef) -> str: + return self._format_model_validation_failure(ref, "missing model") + + @staticmethod + def _format_model_validation_failure( + ref: ConfiguredChatModelRef, problem: str + ) -> str: + return ( + f"sources={','.join(ref.sources)} provider={ref.provider_id} " + f"model={ref.model_id} problem={problem}" + ) diff --git a/src/free_claude_code/providers/stream_recovery.py b/src/free_claude_code/providers/stream_recovery.py new file mode 100644 index 0000000..4ea1816 --- /dev/null +++ b/src/free_claude_code/providers/stream_recovery.py @@ -0,0 +1,222 @@ +"""Provider-owned stream holdback and recovery decisions.""" + +import time +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +import httpx +import openai + +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.core.trace import trace_event + +from .failure_policy import retryable_transient_status + +EARLY_TRANSPARENT_TOTAL_ATTEMPTS = 5 +EARLY_TRANSPARENT_MAX_RETRIES = EARLY_TRANSPARENT_TOTAL_ATTEMPTS - 1 +MIDSTREAM_RECOVERY_ATTEMPTS = 5 +EARLY_HOLDBACK_SECONDS = 0.75 +RECOVERY_BUFFER_MAX_BYTES = 65_536 + + +class TruncatedProviderStreamError(RuntimeError): + """An upstream stream ended without its required terminal marker.""" + + +class RecoveryFailureAction(StrEnum): + """How one provider stream should respond to an upstream failure.""" + + EARLY_RETRY = "early_retry" + MIDSTREAM_RECOVERY = "midstream_recovery" + FINAL_ERROR = "final_error" + + +@dataclass(frozen=True, slots=True) +class RecoveryDecision: + """Failure decision for one provider stream attempt.""" + + action: RecoveryFailureAction + retryable: bool + committed: bool + has_buffered: bool + early_retry_attempt: int | None = None + midstream_recovery_attempt: int | None = None + + +class RecoveryHoldbackBuffer: + """Briefly retain SSE so early cutoffs can be retried invisibly.""" + + def __init__( + self, + *, + holdback_seconds: float = EARLY_HOLDBACK_SECONDS, + max_bytes: int = RECOVERY_BUFFER_MAX_BYTES, + now: Callable[[], float] | None = None, + ) -> None: + self._holdback_seconds = holdback_seconds + self._max_bytes = max_bytes + self._now = now or time.monotonic + self._events: list[str] = [] + self._bytes = 0 + self._started_at: float | None = None + self.committed = False + + def push(self, event: str) -> list[str]: + if self.committed: + return [event] + if self._started_at is None: + self._started_at = self._now() + self._events.append(event) + self._bytes += len(event.encode("utf-8", errors="replace")) + if ( + self._bytes >= self._max_bytes + or self._now() - self._started_at >= self._holdback_seconds + ): + return self.flush() + return [] + + def flush(self) -> list[str]: + if self.committed: + return [] + self.committed = True + events = self._events + self._events = [] + self._bytes = 0 + self._started_at = None + return events + + def discard(self) -> None: + self._events = [] + self._bytes = 0 + self._started_at = None + + @property + def has_buffered(self) -> bool: + return bool(self._events) + + +class RecoveryController: + """Own holdback and retry counters for one provider stream lifecycle.""" + + def __init__(self, *, provider_name: str, request_id: str | None) -> None: + self._provider_name = provider_name + self._request_id = request_id + self._holdback = RecoveryHoldbackBuffer() + self._early_retry_count = 0 + self._midstream_recovery_count = 0 + + @property + def committed(self) -> bool: + return self._holdback.committed + + @property + def has_buffered(self) -> bool: + return self._holdback.has_buffered + + @property + def early_retries(self) -> int: + return self._early_retry_count + + @property + def midstream_recoveries(self) -> int: + return self._midstream_recovery_count + + def push(self, event: str) -> list[str]: + return self._holdback.push(event) + + def flush(self) -> list[str]: + return self._holdback.flush() + + def discard(self) -> None: + self._holdback.discard() + + def flush_uncommitted(self, decision: RecoveryDecision) -> list[str]: + if not decision.committed and decision.has_buffered: + return self.flush() + return [] + + def advance_failure( + self, + error: BaseException, + *, + stream_opened: bool, + generated_output: bool, + complete_tool_salvageable: bool, + ) -> RecoveryDecision: + retryable = is_retryable_stream_error(error) + committed = self._holdback.committed + has_buffered = self._holdback.has_buffered + + if ( + retryable + and stream_opened + and not committed + and not complete_tool_salvageable + and self._early_retry_count < EARLY_TRANSPARENT_MAX_RETRIES + ): + self._early_retry_count += 1 + self._holdback.discard() + self._holdback = RecoveryHoldbackBuffer() + trace_event( + stage="provider", + event="provider.recovery.early_retry", + source="provider", + provider=self._provider_name, + request_id=self._request_id, + retry_attempt=self._early_retry_count, + retryable=True, + ) + return RecoveryDecision( + action=RecoveryFailureAction.EARLY_RETRY, + retryable=True, + committed=False, + has_buffered=has_buffered, + early_retry_attempt=self._early_retry_count, + ) + + if ( + retryable + and generated_output + and self._midstream_recovery_count < MIDSTREAM_RECOVERY_ATTEMPTS + ): + self._midstream_recovery_count += 1 + return RecoveryDecision( + action=RecoveryFailureAction.MIDSTREAM_RECOVERY, + retryable=True, + committed=committed, + has_buffered=has_buffered, + midstream_recovery_attempt=self._midstream_recovery_count, + ) + + return RecoveryDecision( + action=RecoveryFailureAction.FINAL_ERROR, + retryable=retryable, + committed=committed, + has_buffered=has_buffered, + ) + + +def is_retryable_stream_error(exc: BaseException) -> bool: + """Return whether one stream failure qualifies for retry or recovery.""" + if isinstance(exc, TruncatedProviderStreamError): + return True + if isinstance(exc, ExecutionFailure): + return exc.retryable + if isinstance(exc, openai.AuthenticationError | openai.BadRequestError): + return False + if retryable_transient_status(exc) is not None: + return True + return isinstance( + exc, + ( + TimeoutError, + httpx.ReadTimeout, + httpx.ReadError, + httpx.RemoteProtocolError, + httpx.ConnectError, + httpx.NetworkError, + openai.APITimeoutError, + openai.APIConnectionError, + ), + ) diff --git a/src/free_claude_code/runtime/__init__.py b/src/free_claude_code/runtime/__init__.py new file mode 100644 index 0000000..570f323 --- /dev/null +++ b/src/free_claude_code/runtime/__init__.py @@ -0,0 +1 @@ +"""Application composition and process-lifetime resource ownership.""" diff --git a/src/free_claude_code/runtime/application.py b/src/free_claude_code/runtime/application.py new file mode 100644 index 0000000..94a644f --- /dev/null +++ b/src/free_claude_code/runtime/application.py @@ -0,0 +1,481 @@ +"""Single owner for application startup, shutdown, and runtime operations.""" + +import asyncio +import inspect +import logging +import os +import traceback +from collections.abc import Awaitable, Callable, Mapping +from typing import Any + +from loguru import logger + +import free_claude_code.cli.managed as cli_managed +import free_claude_code.messaging.session as messaging_session +import free_claude_code.messaging.workflow as messaging_workflow_module +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.application.ports import StopResult +from free_claude_code.config.admin.persistence import ( + PreparedAdminUpdate, + commit_prepared_admin_update, + prepare_admin_update, +) +from free_claude_code.config.admin.status import provider_config_status +from free_claude_code.config.admin.values import load_value_state +from free_claude_code.config.env_files import ( + ANTHROPIC_AUTH_TOKEN_ENV, + process_env_key_is_effective, +) +from free_claude_code.config.model_refs import parse_provider_type +from free_claude_code.config.paths import messaging_state_dir_path +from free_claude_code.config.server_urls import local_admin_url, local_proxy_root_url +from free_claude_code.config.settings import Settings, get_settings +from free_claude_code.messaging.platforms import factory as messaging_platform_factory +from free_claude_code.messaging.platforms.factory import MessagingPlatformOptions +from free_claude_code.messaging.platforms.ports import ( + MessagingPlatformComponents, + MessagingRuntime, +) +from free_claude_code.messaging.voice import Transcriber + +from .provider_manager import ProviderRuntimeManager + +RestartCallback = Callable[[], Awaitable[None] | None] + + +async def best_effort( + name: str, + awaitable: Awaitable[Any], + *, + log_verbose_errors: bool = False, +) -> bool: + """Run one cleanup step and report whether it completed. + + The lifecycle owner intentionally applies no generic timeout here. Cancelling + an arbitrary cleanup at a deadline can abandon a half-closed SDK, thread, or + provider resource; resource-specific cleanup or the process supervisor owns + any force-termination deadline. + """ + try: + await awaitable + except Exception as exc: + if log_verbose_errors: + logger.warning( + "Shutdown step failed: {}: {}: {}", + name, + type(exc).__name__, + exc, + ) + else: + logger.warning( + "Shutdown step failed: {}: exc_type={}", + name, + type(exc).__name__, + ) + return False + return True + + +def warn_if_process_auth_token(settings: Settings) -> None: + """Warn when server auth was implicitly inherited from the shell.""" + model_config = getattr(settings, "model_config", Settings.model_config) + if process_env_key_is_effective(model_config, ANTHROPIC_AUTH_TOKEN_ENV): + logger.warning( + "ANTHROPIC_AUTH_TOKEN is set in the process environment but not in " + "a configured .env file. The proxy will require that token. Add " + "ANTHROPIC_AUTH_TOKEN= to .env to disable proxy auth, or set the " + "same token in .env to make server auth explicit." + ) + + +def startup_failure_message(settings: Settings, exc: Exception) -> str: + """Return the existing concise ASGI startup failure message.""" + if isinstance(exc, ApplicationUnavailableError): + return exc.message.strip() or "Server startup failed." + if settings.log_api_error_tracebacks: + return f"{type(exc).__name__}: {exc}" + return f"Server startup failed: exc_type={type(exc).__name__}" + + +class ApplicationRuntime: + """Own every process-lifetime resource used by one server instance.""" + + def __init__( + self, + provider_manager: ProviderRuntimeManager, + *, + transcriber: Transcriber | None, + restart_callback: RestartCallback | None = None, + ) -> None: + self.provider_manager = provider_manager + self._transcriber = transcriber + self._restart_callback = restart_callback + self._config_lock = asyncio.Lock() + self._pending_fields: list[str] = [] + self._messaging_runtime: MessagingRuntime | None = None + self._messaging_workflow: messaging_workflow_module.MessagingWorkflow | None = ( + None + ) + self._cli_manager: cli_managed.ManagedClaudeSessionManager | None = None + self._started = False + self._closed = False + self._provider_manager_closed = False + self._close_lock = asyncio.Lock() + + @property + def settings(self) -> Settings: + return self.provider_manager.current_settings() + + @property + def is_closed(self) -> bool: + """Whether this runtime released its complete ownership graph.""" + return self._closed + + async def start(self) -> None: + if self._started: + return + logger.info("Starting Claude Code Proxy...") + try: + warn_if_process_auth_token(self.settings) + await self._validate_configured_models_best_effort() + self.provider_manager.start_model_list_refresh() + await self._start_messaging_if_configured() + logging.getLogger("uvicorn.error").info( + "Admin UI: %s (local-only)", + local_admin_url(self.settings), + ) + self._started = True + except asyncio.CancelledError: + await self.close() + raise + except Exception as exc: + logger.error( + "Startup failed:\n{}", + startup_failure_message(self.settings, exc), + ) + await self.close() + raise + + async def close(self) -> bool: + async with self._close_lock: + if self._closed: + return True + logger.info("Shutdown requested, cleaning up...") + self._closed = await self._close_owned_resources() + if self._closed: + self._started = False + logger.info("Server shut down cleanly") + else: + logger.warning( + "Server shutdown incomplete; owned resources remain for retry" + ) + return self._closed + + async def apply_admin_config( + self, + updates: Mapping[str, Any], + ) -> dict[str, Any]: + """Apply one validated config update without splitting runtime ownership.""" + async with self._config_lock: + prepared = prepare_admin_update(updates) + if not prepared.valid: + return prepared.applied_response() + assert prepared.settings is not None + + if prepared.pending_fields: + result = self._commit_admin_update(prepared) + restart = self._restart_metadata( + prepared.pending_fields, + prepared.settings, + ) + result["restart"] = restart + self._pending_fields = ( + [] if restart["automatic"] else list(prepared.pending_fields) + ) + return result + + result: dict[str, Any] = {} + + def commit() -> None: + result.update(self._commit_admin_update(prepared)) + + await self.provider_manager.replace( + prepared.settings, + commit=commit, + reason="admin_apply", + ) + self._pending_fields = [] + result["restart"] = self._restart_metadata((), prepared.settings) + return result + + def admin_status(self) -> dict[str, Any]: + settings = self.settings + return { + "status": "running", + "host": settings.host, + "port": settings.port, + "model": settings.model, + "provider": parse_provider_type(settings.model), + "pending_fields": list(self._pending_fields), + "provider_status": provider_config_status(load_value_state()), + "cached_models": { + provider_id: sorted(model_ids) + for provider_id, model_ids in self.provider_manager.cached_model_ids().items() + }, + } + + async def test_provider(self, provider_id: str) -> dict[str, Any]: + lease = await self.provider_manager.acquire() + try: + provider = lease.resolve_provider(provider_id) + infos = await provider.list_model_infos() + except Exception as exc: + return { + "provider_id": provider_id, + "ok": False, + "error_type": type(exc).__name__, + } + finally: + await lease.release() + self.provider_manager.cache_model_infos(provider_id, infos) + return { + "provider_id": provider_id, + "ok": True, + "models": sorted(info.model_id for info in infos), + } + + async def refresh_models(self) -> dict[str, Any]: + await self.provider_manager.refresh_model_list_cache() + return { + "cached_models": { + provider_id: sorted(model_ids) + for provider_id, model_ids in self.provider_manager.cached_model_ids().items() + } + } + + async def request_restart(self) -> None: + callback = self._restart_callback + if callback is None: + return + result = callback() + if inspect.isawaitable(result): + await result + + async def stop_all(self) -> StopResult | None: + if self._messaging_workflow is not None: + outcome = await self._messaging_workflow.stop_all_tasks() + return StopResult(cancelled_count=outcome.cancelled_count) + if self._cli_manager is not None: + await self._cli_manager.stop_all() + return StopResult(source="cli_manager") + return None + + def _commit_admin_update( + self, + prepared: PreparedAdminUpdate, + ) -> dict[str, Any]: + result = commit_prepared_admin_update(prepared) + get_settings.cache_clear() + return result + + def _restart_metadata( + self, + fields: tuple[str, ...], + settings: Settings, + ) -> dict[str, Any]: + automatic = bool(fields and self._restart_callback is not None) + return { + "required": bool(fields), + "automatic": automatic, + "admin_url": local_admin_url(settings) if automatic else None, + "fields": list(fields), + } + + async def _validate_configured_models_best_effort(self) -> None: + try: + await self.provider_manager.validate_configured_models() + except ApplicationUnavailableError as exc: + logger.warning( + "Configured provider model validation failed during startup; " + "server will continue and requests will fail at provider resolution " + "when config is incomplete. {}", + exc.message, + ) + + async def _start_messaging_if_configured(self) -> None: + try: + components = messaging_platform_factory.create_messaging_components( + self.settings.messaging_platform, + self._messaging_options(), + ) + if components is not None: + await self._start_messaging_workflow(components) + except ImportError as exc: + cleaned = await self._cleanup_messaging() + if self.settings.log_api_error_tracebacks: + logger.warning("Messaging module import error: {}", exc) + else: + logger.warning( + "Messaging module import error: exc_type={}", + type(exc).__name__, + ) + if not cleaned: + raise RuntimeError("Messaging startup cleanup incomplete") from exc + except Exception as exc: + cleaned = await self._cleanup_messaging() + if self.settings.log_api_error_tracebacks: + logger.error("Failed to start messaging platform: {}", exc) + logger.error(traceback.format_exc()) + else: + logger.error( + "Failed to start messaging platform: exc_type={}", + type(exc).__name__, + ) + if not cleaned: + raise RuntimeError("Messaging startup cleanup incomplete") from exc + + def _messaging_options(self) -> MessagingPlatformOptions: + settings = self.settings + return MessagingPlatformOptions( + telegram_bot_token=settings.telegram_bot_token, + allowed_telegram_user_id=settings.allowed_telegram_user_id, + telegram_proxy_url=settings.telegram_proxy_url, + discord_bot_token=settings.discord_bot_token, + allowed_discord_channels=settings.allowed_discord_channels, + transcriber=self._transcriber, + messaging_rate_limit=settings.messaging_rate_limit, + messaging_rate_window=settings.messaging_rate_window, + log_raw_messaging_content=settings.log_raw_messaging_content, + log_messaging_error_details=settings.log_messaging_error_details, + log_api_error_tracebacks=settings.log_api_error_tracebacks, + ) + + async def _start_messaging_workflow( + self, + components: MessagingPlatformComponents, + ) -> None: + settings = self.settings + self._messaging_runtime = components.runtime + workspace = ( + os.path.abspath(settings.allowed_dir) + if settings.allowed_dir + else os.getcwd() + ) + os.makedirs(workspace, exist_ok=True) + data_path = os.path.abspath(messaging_state_dir_path()) + os.makedirs(data_path, exist_ok=True) + allowed_dirs = [workspace] if settings.allowed_dir else [] + + self._cli_manager = cli_managed.ManagedClaudeSessionManager( + workspace_path=workspace, + proxy_root_url=local_proxy_root_url(settings), + allowed_dirs=allowed_dirs, + auth_token=settings.anthropic_auth_token, + log_raw_cli_diagnostics=settings.log_raw_cli_diagnostics, + log_messaging_error_details=settings.log_messaging_error_details, + ) + session_store = messaging_session.SessionStore( + storage_path=os.path.join(data_path, "sessions.json"), + managed_message_cap=settings.max_message_log_entries_per_chat, + ) + workflow = messaging_workflow_module.MessagingWorkflow( + platform_name=components.name, + outbound=components.outbound, + voice_cancellation=components.voice_cancellation, + cli_manager=self._cli_manager, + session_store=session_store, + debug_platform_edits=settings.debug_platform_edits, + debug_subagent_stack=settings.debug_subagent_stack, + log_raw_cli_diagnostics=settings.log_raw_cli_diagnostics, + log_messaging_error_details=settings.log_messaging_error_details, + ) + self._messaging_workflow = workflow + workflow.restore() + components.runtime.on_message(workflow.handle_message) + await components.runtime.start() + await workflow.repair_restored_statuses() + if components.startup_notice is not None: + await workflow.publish_startup_notice(components.startup_notice) + logger.info("{} platform started with messaging workflow", components.name) + + async def _close_owned_resources(self) -> bool: + if not await self._cleanup_messaging(): + return False + if not await self._cleanup_transcriber(): + return False + if self._provider_manager_closed: + return True + verbose = self.settings.log_api_error_tracebacks + self._provider_manager_closed = await best_effort( + "provider_manager.close", + self.provider_manager.close(), + log_verbose_errors=verbose, + ) + return self._provider_manager_closed + + async def _cleanup_messaging(self) -> bool: + verbose = self.settings.log_api_error_tracebacks + workflow = self._messaging_workflow + runtime = self._messaging_runtime + cli_manager = self._cli_manager + + if runtime is not None: + quiesced = await best_effort( + "messaging_runtime.quiesce", + runtime.quiesce(), + log_verbose_errors=verbose, + ) + if not quiesced: + # Delivery must remain available until ingress is known stopped. + # Retaining the graph lets the next close retry this exact gate. + return False + + if workflow is not None: + closed = await best_effort( + "messaging_workflow.close", + workflow.close(), + log_verbose_errors=verbose, + ) + if not closed: + # Active workflow tasks may still need delivery, transcription, + # CLI sessions, and providers while a later close retries drain. + return False + if self._messaging_workflow is workflow: + self._messaging_workflow = None + if self._cli_manager is cli_manager: + self._cli_manager = None + elif cli_manager is not None: + drained = await best_effort( + "cli_manager.stop_all", + cli_manager.stop_all(), + log_verbose_errors=verbose, + ) + if not drained: + return False + if self._cli_manager is cli_manager: + self._cli_manager = None + + if runtime is not None: + closed = await best_effort( + "messaging_runtime.close", + runtime.close(), + log_verbose_errors=verbose, + ) + if not closed: + return False + if self._messaging_runtime is runtime: + self._messaging_runtime = None + return True + + async def _cleanup_transcriber(self) -> bool: + transcriber = self._transcriber + if transcriber is None: + return True + closed = await best_effort( + "transcriber.close", + transcriber.close(), + log_verbose_errors=self.settings.log_api_error_tracebacks, + ) + if closed and self._transcriber is transcriber: + self._transcriber = None + return closed diff --git a/src/free_claude_code/runtime/asgi.py b/src/free_claude_code/runtime/asgi.py new file mode 100644 index 0000000..654d53b --- /dev/null +++ b/src/free_claude_code/runtime/asgi.py @@ -0,0 +1,64 @@ +"""ASGI lifespan adapter for the application runtime owner.""" + +from typing import Any + +from loguru import logger +from starlette.types import ASGIApp, Receive, Scope, Send + +from .application import ApplicationRuntime, startup_failure_message + + +class RuntimeASGIApp: + """Delegate HTTP to FastAPI and lifespan to `ApplicationRuntime`.""" + + def __init__(self, app: ASGIApp, runtime: ApplicationRuntime) -> None: + self.app = app + self.runtime = runtime + + def __getattr__(self, name: str) -> Any: + return getattr(self.app, name) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "lifespan": + await self.app(scope, receive, send) + return + await self._lifespan(receive, send) + + async def _lifespan(self, receive: Receive, send: Send) -> None: + started = False + while True: + message = await receive() + if message["type"] == "lifespan.startup": + try: + await self.runtime.start() + except Exception as exc: + await send( + { + "type": "lifespan.startup.failed", + "message": startup_failure_message( + self.runtime.settings, + exc, + ), + } + ) + return + started = True + await send({"type": "lifespan.startup.complete"}) + continue + + if message["type"] == "lifespan.shutdown": + if started: + try: + closed = await self.runtime.close() + except Exception as exc: + logger.error( + "Shutdown failed: exc_type={}", + type(exc).__name__, + ) + await send({"type": "lifespan.shutdown.failed", "message": ""}) + return + if not closed: + await send({"type": "lifespan.shutdown.failed", "message": ""}) + return + await send({"type": "lifespan.shutdown.complete"}) + return diff --git a/src/free_claude_code/runtime/bootstrap.py b/src/free_claude_code/runtime/bootstrap.py new file mode 100644 index 0000000..e13c255 --- /dev/null +++ b/src/free_claude_code/runtime/bootstrap.py @@ -0,0 +1,53 @@ +"""Single production composition root for the FCC server.""" + +import os +from pathlib import Path + +from free_claude_code.api.app import create_app +from free_claude_code.api.ports import ApiServices +from free_claude_code.config.logging_config import configure_logging +from free_claude_code.config.paths import server_log_path +from free_claude_code.config.settings import Settings +from free_claude_code.messaging.transcription import TranscriptionService +from free_claude_code.messaging.voice import Transcriber +from free_claude_code.providers.nvidia_nim.voice import NvidiaNimTranscriber + +from .application import ApplicationRuntime, RestartCallback +from .asgi import RuntimeASGIApp +from .provider_manager import ProviderRuntimeManager + + +def build_asgi_app( + settings: Settings, + restart_callback: RestartCallback | None = None, +) -> RuntimeASGIApp: + """Construct the complete server application and its resource owner.""" + log_path = Path(os.getenv("LOG_FILE", server_log_path())) + configure_logging(log_path, verbose_third_party=settings.log_raw_api_payloads) + provider_manager = ProviderRuntimeManager(settings) + runtime = ApplicationRuntime( + provider_manager, + transcriber=_create_transcriber(settings), + restart_callback=restart_callback, + ) + services = ApiServices( + requests=provider_manager, + admin=runtime, + tasks=runtime, + ) + return RuntimeASGIApp(create_app(services), runtime) + + +def _create_transcriber(settings: Settings) -> Transcriber | None: + if not settings.voice_note_enabled: + return None + if settings.whisper_device == "nvidia_nim": + return NvidiaNimTranscriber( + model=settings.whisper_model, + api_key=settings.nvidia_nim_api_key, + ) + return TranscriptionService( + model=settings.whisper_model, + device=settings.whisper_device, + huggingface_api_key=settings.huggingface_api_key, + ) diff --git a/src/free_claude_code/runtime/provider_manager.py b/src/free_claude_code/runtime/provider_manager.py new file mode 100644 index 0000000..b9390ab --- /dev/null +++ b/src/free_claude_code/runtime/provider_manager.py @@ -0,0 +1,399 @@ +"""Single-owner provider generations and application model catalog.""" + +import asyncio +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field + +from loguru import logger + +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.settings import Settings +from free_claude_code.core.trace import trace_event +from free_claude_code.providers.base import BaseProvider +from free_claude_code.providers.runtime import ProviderRuntime +from free_claude_code.providers.runtime.discovery import ProviderModelDiscovery +from free_claude_code.providers.runtime.model_cache import ProviderModelCache +from free_claude_code.providers.runtime.validation import ConfiguredModelValidator + +ProviderRuntimeFactory = Callable[[Settings], ProviderRuntime] +CommitConfig = Callable[[], None] + + +@dataclass(slots=True, eq=False) +class _ProviderGeneration: + generation_id: int + settings: Settings + runtime: ProviderRuntime + active_leases: int = 0 + retired: bool = False + closed: bool = False + drained: asyncio.Event = field(default_factory=asyncio.Event) + cleanup_task: asyncio.Task[bool] | None = None + + def __post_init__(self) -> None: + self.drained.set() + + +class ProviderGenerationLease: + """Idempotent lease retaining one provider generation.""" + + def __init__( + self, + manager: ProviderRuntimeManager, + generation: _ProviderGeneration, + ) -> None: + self._manager = manager + self._generation = generation + self._released = False + + @property + def generation_id(self) -> int: + return self._generation.generation_id + + @property + def settings(self) -> Settings: + return self._generation.settings + + def is_provider_cached(self, provider_id: str) -> bool: + return self._generation.runtime.is_cached(provider_id) + + def resolve_provider(self, provider_id: str) -> BaseProvider: + return self._generation.runtime.resolve_provider(provider_id) + + async def release(self) -> None: + if self._released: + return + self._released = True + await self._manager._release(self._generation) + + async def __aenter__(self) -> ProviderGenerationLease: + return self + + async def __aexit__(self, *_exc: object) -> None: + await self.release() + + +class ProviderRuntimeManager: + """Own provider generations, leases, discovery, and model metadata.""" + + def __init__( + self, + settings: Settings, + *, + runtime_factory: ProviderRuntimeFactory = ProviderRuntime, + ) -> None: + self._runtime_factory = runtime_factory + self._replace_lock = asyncio.Lock() + self._close_lock = asyncio.Lock() + self._model_cache = ProviderModelCache() + self._refresh_task: asyncio.Task[None] | None = None + self._next_generation_id = 2 + self._retired: dict[int, _ProviderGeneration] = {} + self._unpublished: set[ProviderRuntime] = set() + self._closing = False + self._closed = False + self._current = _ProviderGeneration( + generation_id=1, + settings=settings, + runtime=runtime_factory(settings), + ) + self._trace_published(self._current, previous=None, reason="startup") + + @property + def current_generation_id(self) -> int: + return self._current.generation_id + + async def acquire(self) -> ProviderGenerationLease: + if self._closing or self._closed: + raise ApplicationUnavailableError("Provider runtime is shutting down.") + generation = self._current + generation.active_leases += 1 + generation.drained.clear() + return ProviderGenerationLease(self, generation) + + def current_settings(self) -> Settings: + return self._current.settings + + def cached_model_ids(self) -> dict[str, frozenset[str]]: + return self._model_cache.cached_model_ids() + + def cached_model_supports_thinking( + self, provider_id: str, model_id: str + ) -> bool | None: + return self._model_cache.cached_model_supports_thinking(provider_id, model_id) + + def cached_prefixed_model_infos(self) -> tuple[ProviderModelInfo, ...]: + return self._model_cache.cached_prefixed_model_infos() + + def cache_model_infos( + self, + provider_id: str, + model_infos: Iterable[ProviderModelInfo], + ) -> None: + self._model_cache.cache_model_infos(provider_id, model_infos) + + async def validate_configured_models(self) -> None: + lease = await self.acquire() + try: + validator = ConfiguredModelValidator( + lease.settings, + lease.resolve_provider, + self._model_cache, + ) + await validator.validate_configured_models() + finally: + await lease.release() + + def start_model_list_refresh(self) -> None: + """Start one non-blocking refresh for the current generation.""" + if self._closing or self._closed: + return + if self._refresh_task is not None and not self._refresh_task.done(): + return + generation = self._current + self._refresh_task = asyncio.create_task( + self._refresh_generation(generation, only_missing=True) + ) + + async def refresh_model_list_cache(self) -> None: + """Run an explicit full refresh without racing replacement.""" + async with self._replace_lock: + if self._closing or self._closed: + raise ApplicationUnavailableError("Provider runtime is shutting down.") + await self._cancel_refresh() + await self._refresh_generation(self._current, only_missing=False) + + async def replace( + self, + settings: Settings, + *, + commit: CommitConfig, + reason: str = "admin_apply", + ) -> int: + """Prepare, commit, and atomically publish one replacement generation.""" + async with self._replace_lock: + if self._closing or self._closed: + raise ApplicationUnavailableError("Provider runtime is shutting down.") + await self._cancel_refresh() + await self._retry_unpublished_cleanup() + candidate_id = self._next_generation_id + candidate_runtime: ProviderRuntime | None = None + try: + candidate_runtime = self._runtime_factory(settings) + commit() + except Exception as exc: + trace_event( + stage="runtime", + event="provider_generation.replace_failed", + source="runtime", + current_generation_id=self._current.generation_id, + candidate_generation_id=candidate_id, + reason=reason, + exc_type=type(exc).__name__, + ) + if candidate_runtime is not None: + await self._cleanup_unpublished(candidate_runtime) + raise + + self._next_generation_id += 1 + assert candidate_runtime is not None + previous = self._current + candidate = _ProviderGeneration( + generation_id=candidate_id, + settings=settings, + runtime=candidate_runtime, + ) + self._current = candidate + previous.retired = True + self._retired[previous.generation_id] = previous + self._trace_published(candidate, previous=previous, reason=reason) + self._trace_retired(previous, reason=reason) + + self._refresh_task = asyncio.create_task( + self._refresh_generation(candidate, only_missing=False) + ) + if previous.active_leases == 0: + await self._close_generation(previous, forced=False) + return candidate.generation_id + + async def close(self) -> None: + """Reject new leases, drain existing work, and close every generation.""" + async with self._close_lock: + if self._closed: + return + async with self._replace_lock: + self._closing = True + await self._cancel_refresh() + current = self._current + if not current.retired: + current.retired = True + self._retired[current.generation_id] = current + self._trace_retired(current, reason="shutdown") + generations = tuple(self._retired.values()) + + await asyncio.gather( + *(generation.drained.wait() for generation in generations) + ) + generation_results = await asyncio.gather( + *( + self._close_generation(generation, forced=False) + for generation in generations + ) + ) + unpublished_closed = await self._retry_unpublished_cleanup() + if not all(generation_results) or not unpublished_closed: + raise RuntimeError("One or more provider runtimes failed to close.") + self._model_cache.clear() + self._closed = True + + async def _release(self, generation: _ProviderGeneration) -> None: + if generation.active_leases <= 0: + return + generation.active_leases -= 1 + if generation.active_leases != 0: + return + generation.drained.set() + if generation.retired and not self._closing: + await self._close_generation(generation, forced=False) + + async def _refresh_generation( + self, + generation: _ProviderGeneration, + *, + only_missing: bool, + ) -> None: + if generation.closed: + return + generation.active_leases += 1 + generation.drained.clear() + try: + discovery = ProviderModelDiscovery( + generation.settings, + generation.runtime.resolve_provider, + self._model_cache, + ) + await discovery.refresh_model_list_cache(only_missing=only_missing) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "Provider model discovery task failed: exc_type={}", + type(exc).__name__, + ) + finally: + await self._release(generation) + + async def _cancel_refresh(self) -> None: + task = self._refresh_task + self._refresh_task = None + if task is None or task.done(): + return + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + async def _cleanup_unpublished(self, runtime: ProviderRuntime) -> bool: + self._unpublished.add(runtime) + try: + await runtime.cleanup() + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "Unpublished provider generation cleanup failed: exc_type={}", + type(exc).__name__, + ) + return False + self._unpublished.discard(runtime) + return True + + async def _retry_unpublished_cleanup(self) -> bool: + all_closed = True + for runtime in tuple(self._unpublished): + if not await self._cleanup_unpublished(runtime): + all_closed = False + return all_closed + + async def _close_generation( + self, + generation: _ProviderGeneration, + *, + forced: bool, + ) -> bool: + if generation.closed: + return True + if generation.active_leases != 0: + return False + task = generation.cleanup_task + if task is None: + task = asyncio.create_task( + self._run_generation_cleanup(generation, forced=forced), + name=f"provider-generation-cleanup-{generation.generation_id}", + ) + generation.cleanup_task = task + return await asyncio.shield(task) + + async def _run_generation_cleanup( + self, + generation: _ProviderGeneration, + *, + forced: bool, + ) -> bool: + task = asyncio.current_task() + try: + try: + await generation.runtime.cleanup() + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "Provider generation cleanup failed: generation_id={} exc_type={}", + generation.generation_id, + type(exc).__name__, + ) + return False + + generation.closed = True + self._retired.pop(generation.generation_id, None) + trace_event( + stage="runtime", + event="provider_generation.closed", + source="runtime", + generation_id=generation.generation_id, + active_leases=generation.active_leases, + forced=forced, + outcome="ok", + ) + return True + finally: + if not generation.closed and generation.cleanup_task is task: + generation.cleanup_task = None + + @staticmethod + def _trace_published( + generation: _ProviderGeneration, + *, + previous: _ProviderGeneration | None, + reason: str, + ) -> None: + trace_event( + stage="runtime", + event="provider_generation.published", + source="runtime", + generation_id=generation.generation_id, + previous_generation_id=( + previous.generation_id if previous is not None else None + ), + reason=reason, + ) + + @staticmethod + def _trace_retired(generation: _ProviderGeneration, *, reason: str) -> None: + trace_event( + stage="runtime", + event="provider_generation.retired", + source="runtime", + generation_id=generation.generation_id, + active_leases=generation.active_leases, + reason=reason, + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..10436b4 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite package.""" diff --git a/tests/api/support.py b/tests/api/support.py new file mode 100644 index 0000000..c6bc0bb --- /dev/null +++ b/tests/api/support.py @@ -0,0 +1,61 @@ +"""Explicit test composition for the API adapter.""" + +from collections.abc import MutableMapping + +from fastapi import FastAPI + +from free_claude_code.api.app import create_app +from free_claude_code.api.ports import ApiServices +from free_claude_code.config.settings import Settings +from free_claude_code.providers.base import BaseProvider +from free_claude_code.providers.runtime import ProviderRuntime +from free_claude_code.runtime.application import ApplicationRuntime, RestartCallback +from free_claude_code.runtime.provider_manager import ProviderRuntimeManager + + +def create_test_app( + settings: Settings | None = None, + *, + providers: MutableMapping[str, BaseProvider] | None = None, + restart_callback: RestartCallback | None = None, +) -> FastAPI: + """Build an API app with explicit in-memory runtime services.""" + settings = settings or Settings() + if providers is None: + manager = ProviderRuntimeManager(settings) + else: + manager = ProviderRuntimeManager( + settings, + runtime_factory=lambda snapshot: ProviderRuntime( + snapshot, + dict(providers), + ), + ) + runtime = ApplicationRuntime( + manager, + transcriber=None, + restart_callback=restart_callback, + ) + return create_app( + ApiServices( + requests=manager, + admin=runtime, + tasks=runtime, + ) + ) + + +def runtime_for_app(app: FastAPI) -> ApplicationRuntime: + """Return the runtime supplied by :func:`create_test_app`.""" + runtime = app.state.services.admin + if not isinstance(runtime, ApplicationRuntime): + raise TypeError("Test app does not use ApplicationRuntime") + return runtime + + +def provider_manager_for_app(app: FastAPI) -> ProviderRuntimeManager: + """Return the provider manager supplied by :func:`create_test_app`.""" + manager = app.state.services.requests + if not isinstance(manager, ProviderRuntimeManager): + raise TypeError("Test app does not use ProviderRuntimeManager") + return manager diff --git a/tests/api/test_admin.py b/tests/api/test_admin.py new file mode 100644 index 0000000..add2d08 --- /dev/null +++ b/tests/api/test_admin.py @@ -0,0 +1,787 @@ +from pathlib import Path +from unittest.mock import patch + +import httpx +import pytest +from fastapi.testclient import TestClient + +from free_claude_code.config.admin.values import MASKED_SECRET +from free_claude_code.config.server_urls import local_admin_url +from free_claude_code.config.settings import Settings +from tests.api.support import create_test_app + + +def _local_client(app): + return TestClient(app, client=("127.0.0.1", 50000)) + + +def _set_home(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + +def _clear_process_config(monkeypatch) -> None: + for key in ( + "MODEL", + "NVIDIA_NIM_API_KEY", + "HUGGINGFACE_API_KEY", + "OPENROUTER_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "TELEGRAM_PROXY_URL", + "FCC_ENV_FILE", + "CLOUDFLARE_API_TOKEN", + "CLOUDFLARE_ACCOUNT_ID", + "GITHUB_MODELS_TOKEN", + "SAMBANOVA_API_KEY", + "HOST", + "PORT", + "VOICE_NOTE_ENABLED", + "WHISPER_DEVICE", + "LOG_FILE", + "ZAI_BASE_URL", + "CLAUDE_WORKSPACE", + "CLAUDE_CLI_BIN", + ): + monkeypatch.delenv(key, raising=False) + + +def test_admin_page_is_loopback_only(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + app = create_test_app() + + assert _local_client(app).get("/admin").status_code == 200 + remote_client = TestClient(app, client=("203.0.113.10", 50000)) + assert remote_client.get("/admin").status_code == 403 + + +def test_admin_page_no_longer_renders_generated_env_panel(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + app = create_test_app() + + response = _local_client(app).get("/admin") + + assert response.status_code == 200 + assert "Generated Env" not in response.text + assert "envPreview" not in response.text + + +def test_admin_page_no_longer_renders_global_status_header(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + app = create_test_app() + + response = _local_client(app).get("/admin") + + assert response.status_code == 200 + assert "Local Admin" not in response.text + assert "serverStatus" not in response.text + assert "modelBadge" not in response.text + + +def test_admin_static_no_longer_fetches_global_status_header(): + script = Path("src/free_claude_code/api/admin_static/admin.js").read_text( + encoding="utf-8" + ) + + assert 'api("/admin/api/status")' not in script + assert "updateHeader" not in script + assert '"Running"' not in script + assert "serverStatus" not in script + assert "modelBadge" not in script + + +def test_admin_static_hides_managed_source_label(): + script = Path("src/free_claude_code/api/admin_static/admin.js").read_text( + encoding="utf-8" + ) + + assert 'managed_env: "",' in script + assert "hasOwnProperty.call(labels, source)" in script + assert 'parts.push("locked")' in script + assert "sourceEl.textContent = source" in script + + +def test_admin_config_masks_secrets_and_exposes_manifest(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).get("/admin/api/config") + + assert response.status_code == 200 + body = response.json() + keys = {field["key"] for field in body["fields"]} + assert "ANTHROPIC_AUTH_TOKEN" in keys + assert "OPENROUTER_API_KEY" in keys + assert "FIREWORKS_API_KEY" in keys + assert "CLOUDFLARE_API_TOKEN" in keys + assert "CLOUDFLARE_ACCOUNT_ID" in keys + assert "GITHUB_MODELS_TOKEN" in keys + assert "GEMINI_API_KEY" in keys + assert "GROQ_API_KEY" in keys + assert "SAMBANOVA_API_KEY" in keys + assert "TELEGRAM_PROXY_URL" in keys + assert "CEREBRAS_API_KEY" in keys + assert "ZAI_BASE_URL" not in keys + assert "CLAUDE_WORKSPACE" not in keys + assert "CLAUDE_CLI_BIN" not in keys + assert "LOG_FILE" not in keys + auth_field = next( + field for field in body["fields"] if field["key"] == "ANTHROPIC_AUTH_TOKEN" + ) + assert auth_field["secret"] is True + assert auth_field["value"] == MASKED_SECRET + assert auth_field["source"] == "template" + telegram_proxy_field = next( + field for field in body["fields"] if field["key"] == "TELEGRAM_PROXY_URL" + ) + assert telegram_proxy_field["secret"] is True + restart_required = { + field["key"] for field in body["fields"] if field["restart_required"] is True + } + assert { + "ANTHROPIC_AUTH_TOKEN", + "DEBUG_PLATFORM_EDITS", + "DEBUG_SUBAGENT_STACK", + "LOG_RAW_API_PAYLOADS", + "LOG_API_ERROR_TRACEBACKS", + "LOG_RAW_MESSAGING_CONTENT", + "LOG_RAW_CLI_DIAGNOSTICS", + "LOG_MESSAGING_ERROR_DETAILS", + } <= restart_required + + +def test_admin_config_preserves_managed_env_source_contract(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + env_file = tmp_path / ".fcc" / ".env" + env_file.parent.mkdir(parents=True) + env_file.write_text("MODEL=open_router/managed-model\n", encoding="utf-8") + app = create_test_app() + + response = _local_client(app).get("/admin/api/config") + + assert response.status_code == 200 + body = response.json() + model_field = next(field for field in body["fields"] if field["key"] == "MODEL") + assert model_field["source"] == "managed_env" + assert model_field["locked"] is False + + +def test_admin_apply_masks_telegram_proxy_credentials(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + proxy_url = "https://user:password@proxy.example:8443" + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {"TELEGRAM_PROXY_URL": proxy_url}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "TELEGRAM_PROXY_URL=********" in body["env_preview"] + assert proxy_url not in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert f"TELEGRAM_PROXY_URL={proxy_url}" in text + + +def test_admin_validate_rejects_bad_model_shape(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/validate", + json={"values": {"MODEL": "missing-provider-prefix"}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["valid"] is False + assert any("provider type" in error for error in body["errors"]) + + +def test_admin_apply_writes_complete_managed_env_and_masks_preview( + monkeypatch, tmp_path +): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "open_router/test-model", + "OPENROUTER_API_KEY": "router-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "OPENROUTER_API_KEY=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text("utf-8") + assert "MODEL=open_router/test-model" in text + assert "OPENROUTER_API_KEY=router-secret" in text + assert "ANTHROPIC_AUTH_TOKEN=" in text + assert body["restart"] == { + "required": False, + "automatic": False, + "admin_url": None, + "fields": [], + } + + +def test_admin_apply_writes_fireworks_key_and_masks_preview(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "fireworks/test-model", + "FIREWORKS_API_KEY": "fw-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "FIREWORKS_API_KEY=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=fireworks/test-model" in text + assert "FIREWORKS_API_KEY=fw-secret" in text + + +def test_admin_apply_writes_gemini_key_and_masks_preview(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "gemini/models/gemini-3.1-flash-lite", + "GEMINI_API_KEY": "gm-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "GEMINI_API_KEY=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=gemini/models/gemini-3.1-flash-lite" in text + assert "GEMINI_API_KEY=gm-secret" in text + + +def test_admin_apply_writes_groq_key_and_masks_preview(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "groq/llama-3.3-70b-versatile", + "GROQ_API_KEY": "gq-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "GROQ_API_KEY=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=groq/llama-3.3-70b-versatile" in text + assert "GROQ_API_KEY=gq-secret" in text + + +def test_admin_apply_writes_sambanova_key_and_masks_preview(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "sambanova/Meta-Llama-3.3-70B-Instruct", + "SAMBANOVA_API_KEY": "sn-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "SAMBANOVA_API_KEY=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=sambanova/Meta-Llama-3.3-70B-Instruct" in text + assert "SAMBANOVA_API_KEY=sn-secret" in text + + +def test_admin_apply_writes_cerebras_key_and_masks_preview(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "cerebras/llama3.1-8b", + "CEREBRAS_API_KEY": "cb-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "CEREBRAS_API_KEY=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=cerebras/llama3.1-8b" in text + assert "CEREBRAS_API_KEY=cb-secret" in text + + +def test_admin_apply_writes_cloudflare_fields_and_masks_preview(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "cloudflare/@cf/moonshotai/kimi-k2.6", + "CLOUDFLARE_API_TOKEN": "cf-secret", + "CLOUDFLARE_ACCOUNT_ID": "cf-account", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "CLOUDFLARE_API_TOKEN=********" in body["env_preview"] + assert "CLOUDFLARE_ACCOUNT_ID=cf-account" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=cloudflare/@cf/moonshotai/kimi-k2.6" in text + assert "CLOUDFLARE_API_TOKEN=cf-secret" in text + assert "CLOUDFLARE_ACCOUNT_ID=cf-account" in text + + +def test_admin_apply_writes_huggingface_key_and_masks_preview(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "huggingface/openai/gpt-oss-120b:fastest", + "HUGGINGFACE_API_KEY": "hf-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert body["pending_fields"] == [] + assert "HUGGINGFACE_API_KEY=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=huggingface/openai/gpt-oss-120b:fastest" in text + assert "HUGGINGFACE_API_KEY=hf-secret" in text + + +@pytest.mark.parametrize( + ("device", "credential_key"), + [ + ("nvidia_nim", "NVIDIA_NIM_API_KEY"), + ("cpu", "HUGGINGFACE_API_KEY"), + ], +) +def test_admin_key_change_requires_restart_for_active_voice_backend( + monkeypatch, + tmp_path, + device, + credential_key, +): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + env_file = tmp_path / ".fcc" / ".env" + env_file.parent.mkdir(parents=True) + env_file.write_text( + "\n".join( + [ + "VOICE_NOTE_ENABLED=true", + f"WHISPER_DEVICE={device}", + f"{credential_key}=old-key", + "", + ] + ), + encoding="utf-8", + ) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {credential_key: "new-key"}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert body["pending_fields"] == [credential_key] + assert body["restart"] == { + "required": True, + "automatic": False, + "admin_url": None, + "fields": [credential_key], + } + + +@pytest.mark.parametrize( + ("key", "initial", "updated"), + [ + ("ANTHROPIC_AUTH_TOKEN", "old-token", "new-token"), + ("DEBUG_PLATFORM_EDITS", "true", "false"), + ("DEBUG_SUBAGENT_STACK", "true", "false"), + ("LOG_RAW_API_PAYLOADS", "true", "false"), + ("LOG_API_ERROR_TRACEBACKS", "true", "false"), + ("LOG_RAW_MESSAGING_CONTENT", "true", "false"), + ("LOG_RAW_CLI_DIAGNOSTICS", "true", "false"), + ("LOG_MESSAGING_ERROR_DETAILS", "true", "false"), + ], +) +def test_admin_constructor_captured_setting_requires_restart( + monkeypatch, + tmp_path, + key, + initial, + updated, +): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + env_file = tmp_path / ".fcc" / ".env" + env_file.parent.mkdir(parents=True) + env_file.write_text(f"{key}={initial}\n", encoding="utf-8") + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {key: updated}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert body["pending_fields"] == [key] + assert body["restart"] == { + "required": True, + "automatic": False, + "admin_url": None, + "fields": [key], + } + + +def test_admin_apply_writes_cohere_key_and_masks_preview(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "cohere/command-a-plus-05-2026", + "COHERE_API_KEY": "cohere-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "COHERE_API_KEY=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=cohere/command-a-plus-05-2026" in text + assert "COHERE_API_KEY=cohere-secret" in text + + +def test_admin_apply_writes_github_models_token_and_masks_preview( + monkeypatch, tmp_path +): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={ + "values": { + "MODEL": "github_models/openai/gpt-4.1", + "GITHUB_MODELS_TOKEN": "github-secret", + } + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert "GITHUB_MODELS_TOKEN=********" in body["env_preview"] + env_file = tmp_path / ".fcc" / ".env" + text = env_file.read_text(encoding="utf-8") + assert "MODEL=github_models/openai/gpt-4.1" in text + assert "GITHUB_MODELS_TOKEN=github-secret" in text + + +def test_admin_apply_preserves_hidden_diagnostics_and_smoke_values( + monkeypatch, tmp_path +): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + env_file = tmp_path / ".fcc" / ".env" + env_file.parent.mkdir(parents=True) + env_file.write_text( + "\n".join( + [ + "MODEL=nvidia_nim/old-model", + "LOG_RAW_API_PAYLOADS=true", + "FCC_SMOKE_MODEL_ZAI=zai/smoke-model", + "", + ] + ), + encoding="utf-8", + ) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {"MODEL": "open_router/test-model"}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + text = env_file.read_text("utf-8") + assert "MODEL=open_router/test-model" in text + assert "LOG_RAW_API_PAYLOADS=true" in text + assert "FCC_SMOKE_MODEL_ZAI=zai/smoke-model" in text + + +def test_admin_apply_omits_stale_zai_base_url(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + env_file = tmp_path / ".fcc" / ".env" + env_file.parent.mkdir(parents=True) + env_file.write_text( + "\n".join( + [ + "MODEL=zai/glm-5.2", + "ZAI_API_KEY=zai-secret", + "ZAI_BASE_URL=https://custom.zai.invalid/v1", + "", + ] + ), + encoding="utf-8", + ) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {"MODEL": "zai/glm-5.2"}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + text = env_file.read_text("utf-8") + assert "ZAI_API_KEY=zai-secret" in text + assert "ZAI_BASE_URL" not in text + + +def test_admin_apply_omits_stale_fixed_claude_runtime_settings(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + env_file = tmp_path / ".fcc" / ".env" + env_file.parent.mkdir(parents=True) + env_file.write_text( + "\n".join( + [ + "MODEL=open_router/test-model", + "CLAUDE_WORKSPACE=C:/custom/workspace", + "CLAUDE_CLI_BIN=claude-custom", + "", + ] + ), + encoding="utf-8", + ) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {"MODEL": "open_router/test-model"}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + text = env_file.read_text("utf-8") + assert "MODEL=open_router/test-model" in text + assert "CLAUDE_WORKSPACE" not in text + assert "CLAUDE_CLI_BIN" not in text + + +def test_admin_apply_restart_required_reports_automatic_restart(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + callbacks: list[str] = [] + + async def restart_callback() -> None: + callbacks.append("restart") + + app = create_test_app(restart_callback=restart_callback) + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {"PORT": "9090"}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert body["pending_fields"] == ["PORT"] + assert body["restart"] == { + "required": True, + "automatic": True, + "admin_url": "http://127.0.0.1:9090/admin", + "fields": ["PORT"], + } + assert callbacks == ["restart"] + + +def test_admin_apply_restart_required_reports_manual_fallback(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {"PORT": "9091"}}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["applied"] is True + assert body["pending_fields"] == ["PORT"] + assert body["restart"] == { + "required": True, + "automatic": False, + "admin_url": None, + "fields": ["PORT"], + } + + +def test_admin_process_env_values_are_locked_and_not_written(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + monkeypatch.setenv("MODEL", "open_router/process-model") + app = create_test_app() + + config = _local_client(app).get("/admin/api/config").json() + model_field = next(field for field in config["fields"] if field["key"] == "MODEL") + assert model_field["locked"] is True + assert model_field["source"] == "process" + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {"MODEL": "deepseek/managed-model"}}, + ) + + assert response.status_code == 200 + env_file = tmp_path / ".fcc" / ".env" + assert "deepseek/managed-model" not in env_file.read_text("utf-8") + + +def test_admin_first_apply_migrates_repo_env(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + monkeypatch.chdir(tmp_path) + (tmp_path / ".env").write_text( + "MODEL=deepseek/deepseek-chat\nDEEPSEEK_API_KEY=deepseek-secret\n", + encoding="utf-8", + ) + app = create_test_app() + + config = _local_client(app).get("/admin/api/config").json() + model_field = next(field for field in config["fields"] if field["key"] == "MODEL") + assert model_field["value"] == "deepseek/deepseek-chat" + assert model_field["source"] == "repo_env" + + response = _local_client(app).post( + "/admin/api/config/apply", + json={"values": {}}, + ) + + assert response.status_code == 200 + managed_text = (tmp_path / ".fcc" / ".env").read_text("utf-8") + assert "MODEL=deepseek/deepseek-chat" in managed_text + assert "DEEPSEEK_API_KEY=deepseek-secret" in managed_text + + +def test_admin_local_provider_status_reports_reachable(monkeypatch, tmp_path): + _set_home(monkeypatch, tmp_path) + _clear_process_config(monkeypatch) + app = create_test_app() + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def get(self, url: str): + return httpx.Response(200, json={"data": []}) + + with patch("free_claude_code.api.admin_routes.httpx.AsyncClient", FakeAsyncClient): + response = _local_client(app).get("/admin/api/providers/local-status") + + assert response.status_code == 200 + providers = response.json()["providers"] + assert {provider["status"] for provider in providers} == {"reachable"} + + +def test_admin_launch_url_uses_loopback_for_wildcard_host(): + settings = Settings.model_construct(host="0.0.0.0", port=8082) + + assert local_admin_url(settings) == "http://127.0.0.1:8082/admin" diff --git a/tests/api/test_api.py b/tests/api/test_api.py new file mode 100644 index 0000000..482f0db --- /dev/null +++ b/tests/api/test_api.py @@ -0,0 +1,430 @@ +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from tests.api.support import create_test_app + +app = create_test_app() + +# Mock provider +mock_provider = MagicMock(spec=NvidiaNimProvider) + +# Track stream_response calls for test_model_mapping +_stream_response_calls: list = [] + + +async def _mock_stream_response(*args, **kwargs): + """Minimal async generator for streaming tests.""" + _stream_response_calls.append((args, kwargs)) + yield "event: message_start\ndata: {}\n\n" + yield "[DONE]\n\n" + + +async def _mock_pre_start_rate_limit(*args, **kwargs): + """Provider stream that fails before any downstream-visible SSE chunk.""" + _stream_response_calls.append((args, kwargs)) + raise ExecutionFailure( + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="upstream is busy", + retryable=True, + ) + yield "unreachable" + + +async def _mock_empty_stream(*args, **kwargs): + """Provider stream that completes without a protocol frame.""" + _stream_response_calls.append((args, kwargs)) + if False: + yield "unreachable" + + +def _terminal_json_error(response, *, status_code: int): + assert response.status_code == status_code + assert response.headers["content-type"].startswith("application/json") + assert response.headers["x-should-retry"] == "false" + request_id = response.headers["request-id"] + payload = response.json() + assert payload["request_id"] == request_id + return payload["error"] + + +mock_provider.stream_response = _mock_stream_response + + +@pytest.fixture(scope="module") +def client(): + """HTTP client with provider resolution stubbed; patch only for this file.""" + with ( + patch( + "free_claude_code.api.routes.resolve_provider", + return_value=mock_provider, + ), + TestClient(app) as test_client, + ): + yield test_client + + +def test_root(client: TestClient): + response = client.get("/") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + assert response.headers["request-id"].startswith("req_") + + +def test_health(client: TestClient): + response = client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "healthy" + assert response.headers["request-id"].startswith("req_") + + +def test_models_list(client: TestClient): + response = client.get("/v1/models") + assert response.status_code == 200 + data = response.json() + assert data["has_more"] is False + ids = [item["id"] for item in data["data"]] + assert "claude-sonnet-4-20250514" in ids + assert data["first_id"] == ids[0] + assert data["last_id"] == ids[-1] + assert response.headers["x-request-id"] == response.headers["request-id"] + + +def test_probe_endpoints_return_204_with_allow_headers(client: TestClient): + responses = [ + client.head("/"), + client.options("/"), + client.head("/health"), + client.options("/health"), + client.head("/v1/messages"), + client.options("/v1/messages"), + client.head("/v1/messages/count_tokens"), + client.options("/v1/messages/count_tokens"), + ] + + for response in responses: + assert response.status_code == 204 + assert "Allow" in response.headers + + +def test_create_message_stream(client: TestClient): + """Create message returns streaming response.""" + _stream_response_calls.clear() + payload = { + "model": "claude-3-sonnet", + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 100, + "stream": True, + } + response = client.post("/v1/messages", json=payload) + assert response.status_code == 200 + assert "text/event-stream" in response.headers.get("content-type", "") + content = b"".join(response.iter_bytes()) + assert b"message_start" in content or b"event:" in content + assert _stream_response_calls[0][1]["request_id"] == response.headers["request-id"] + + +def test_create_message_ingress_error_has_request_id_without_terminal_header( + client: TestClient, +): + response = client.post( + "/v1/messages", + json={"model": "test", "messages": [], "max_tokens": 10, "stream": True}, + ) + + assert response.status_code == 400 + assert "x-should-retry" not in response.headers + assert response.json()["request_id"] == response.headers["request-id"] + + +def test_create_message_schema_validation_has_request_id_without_terminal_header( + client: TestClient, +): + response = client.post( + "/v1/messages", + json={"model": "test", "messages": "not-a-list"}, + ) + + assert response.status_code == 422 + assert response.headers["request-id"].startswith("req_") + assert "x-should-retry" not in response.headers + + +def test_create_message_pre_start_provider_error_returns_terminal_json( + client: TestClient, +): + """Pre-start provider failures keep status without enabling client retries.""" + mock_provider.stream_response = _mock_pre_start_rate_limit + _stream_response_calls.clear() + payload = { + "model": "claude-3-sonnet", + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 100, + "stream": True, + } + + with ( + patch("free_claude_code.api.response_streams.trace_event") as trace, + patch("free_claude_code.application.execution.trace_event") as execution_trace, + ): + response = client.post("/v1/messages", json=payload) + + error = _terminal_json_error(response, status_code=429) + assert error == {"type": "rate_limit_error", "message": "upstream is busy"} + request_id = response.headers["request-id"] + assert _stream_response_calls[0][1]["request_id"] == request_id + route_trace = next( + call.kwargs + for call in execution_trace.call_args_list + if call.kwargs.get("event") == "free_claude_code.api.route.resolved" + ) + assert route_trace["request_id"] == request_id + terminal_trace = next( + call.kwargs + for call in trace.call_args_list + if call.kwargs.get("event") + == "free_claude_code.api.response.terminal_execution_error" + ) + assert terminal_trace == { + "stage": "egress", + "event": "free_claude_code.api.response.terminal_execution_error", + "source": "api", + "wire_api": "messages", + "request_id": request_id, + "status_code": 429, + "error_type": "rate_limit_error", + "client_should_retry": False, + "exc_type": "ExecutionFailure", + "failure_kind": "rate_limit", + "provider_retryable": True, + } + mock_provider.stream_response = _mock_stream_response + + +def test_create_message_accepts_system_role_messages(client: TestClient): + """Create message accepts latest-client system messages.""" + mock_provider.stream_response = _mock_stream_response + _stream_response_calls.clear() + payload = { + "model": "claude-3-sonnet", + "messages": [ + {"role": "user", "content": "context"}, + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "Hi"}, + ], + "max_tokens": 100, + "stream": True, + } + + response = client.post("/v1/messages", json=payload) + + assert response.status_code == 200 + routed_request = _stream_response_calls[0][0][0] + assert [message.role for message in routed_request.messages] == ["user", "user"] + assert routed_request.system == "system prompt" + + +def test_model_mapping(client: TestClient): + # Test Haiku mapping + _stream_response_calls.clear() + payload_haiku = { + "model": "claude-3-haiku-20240307", + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 100, + "stream": True, + } + client.post("/v1/messages", json=payload_haiku) + assert len(_stream_response_calls) == 1 + args = _stream_response_calls[0][0] + kwargs = _stream_response_calls[0][1] + assert args[0].model != "claude-3-haiku-20240307" + assert kwargs["thinking_enabled"] is True + + +@pytest.mark.parametrize( + ("failure", "expected_type"), + [ + ( + ExecutionFailure( + FailureKind.AUTHENTICATION, 401, "Invalid Key", retryable=False + ), + "authentication_error", + ), + ( + ExecutionFailure( + FailureKind.INVALID_REQUEST, + 400, + "Invalid request api_key=SECRET useful detail", + retryable=False, + ), + "invalid_request_error", + ), + ( + ExecutionFailure( + FailureKind.RATE_LIMIT, 429, "Too Many Requests", retryable=True + ), + "rate_limit_error", + ), + ( + ExecutionFailure( + FailureKind.OVERLOADED, 529, "Server Overloaded", retryable=True + ), + "overloaded_error", + ), + ( + ExecutionFailure( + FailureKind.UPSTREAM, 503, "Upstream failed", retryable=True + ), + "api_error", + ), + ], +) +def test_provider_execution_errors_preserve_status_and_type( + client: TestClient, + failure: ExecutionFailure, + expected_type: str, +): + base_payload = { + "model": "test", + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 10, + "stream": True, + } + + def _raise_provider_error(*args, **kwargs): + raise failure + + try: + mock_provider.stream_response = _raise_provider_error + response = client.post("/v1/messages", json=base_payload) + error = _terminal_json_error(response, status_code=failure.status_code) + assert error["type"] == expected_type + assert "SECRET" not in error["message"] + if expected_type == "invalid_request_error": + assert "useful detail" in error["message"] + finally: + mock_provider.stream_response = _mock_stream_response + + +def test_empty_provider_stream_returns_terminal_json(client: TestClient): + mock_provider.stream_response = _mock_empty_stream + try: + response = client.post( + "/v1/messages", + json={ + "model": "test", + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 10, + "stream": True, + }, + ) + error = _terminal_json_error(response, status_code=500) + assert error["type"] == "api_error" + assert error["message"] == "Stream ended before emitting a response." + finally: + mock_provider.stream_response = _mock_stream_response + + +def test_generic_stream_exception_returns_terminal_json(client: TestClient): + """Unexpected provider execution failures return detailed terminal JSON.""" + + def _raise_runtime(*args, **kwargs): + raise RuntimeError("unexpected crash") + + mock_provider.stream_response = _raise_runtime + response = client.post( + "/v1/messages", + json={ + "model": "test", + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 10, + "stream": True, + }, + ) + error = _terminal_json_error(response, status_code=500) + assert error["type"] == "api_error" + assert error["message"] == "unexpected crash" + mock_provider.stream_response = _mock_stream_response + + +def test_generic_stream_exception_with_status_code_returns_terminal_json( + client: TestClient, +): + """Ad-hoc status_code attrs do not become retryable HTTP responses.""" + + class ExceptionWithStatus(RuntimeError): + def __init__(self, msg: str, status_code: int = 500): + super().__init__(msg) + self.status_code = status_code + + def _raise_with_status(*args, **kwargs): + raise ExceptionWithStatus("bad gateway", 502) + + mock_provider.stream_response = _raise_with_status + response = client.post( + "/v1/messages", + json={ + "model": "test", + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 10, + "stream": True, + }, + ) + error = _terminal_json_error(response, status_code=500) + assert error["type"] == "api_error" + assert error["message"] == "bad gateway" + mock_provider.stream_response = _mock_stream_response + + +def test_generic_stream_exception_empty_message_returns_non_empty_error( + client: TestClient, +): + """Exceptions with empty __str__ still return a readable HTTP detail.""" + + class SilentError(RuntimeError): + def __str__(self): + return "" + + def _raise_silent(*args, **kwargs): + raise SilentError() + + mock_provider.stream_response = _raise_silent + response = client.post( + "/v1/messages", + json={ + "model": "test", + "messages": [{"role": "user", "content": "Hi"}], + "max_tokens": 10, + "stream": True, + }, + ) + error = _terminal_json_error(response, status_code=500) + assert error["type"] == "api_error" + assert error["message"] != "" + mock_provider.stream_response = _mock_stream_response + + +def test_count_tokens_endpoint(client: TestClient): + """count_tokens endpoint returns token count.""" + response = client.post( + "/v1/messages/count_tokens", + json={"model": "test", "messages": [{"role": "user", "content": "Hello"}]}, + ) + assert response.status_code == 200 + assert "input_tokens" in response.json() + assert response.headers["request-id"].startswith("req_") + + +def test_stop_endpoint_no_workflow_no_cli_503(client: TestClient): + """POST /stop without messaging workflow or cli_manager returns 503.""" + # Ensure no messaging workflow or cli_manager on app state + if hasattr(app.state, "messaging_workflow"): + delattr(app.state, "messaging_workflow") + if hasattr(app.state, "cli_manager"): + delattr(app.state, "cli_manager") + response = client.post("/stop") + assert response.status_code == 503 diff --git a/tests/api/test_api_handlers.py b/tests/api/test_api_handlers.py new file mode 100644 index 0000000..4b636bc --- /dev/null +++ b/tests/api/test_api_handlers.py @@ -0,0 +1,558 @@ +import json +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.responses import JSONResponse, StreamingResponse + +from free_claude_code.api.handlers import ( + MessagesHandler, + ResponsesHandler, + TokenCountHandler, +) +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic.models import ( + Message, + MessagesRequest, + TokenCountRequest, +) +from free_claude_code.core.anthropic.streaming import format_sse_event +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.core.openai_responses import OpenAIResponsesRequest + +_CLASSIFIER_SYSTEM = ( + "You are a security monitor. Respond with yes or no." +) +_CLASSIFIER_USER = ( + "\nUser: review the repo\nWebFetch https://example.com: fetch\n" + "\n immediately." +) + + +class FakeProvider: + def __init__(self, events: list[str] | None = None) -> None: + self.preflight_calls: list[tuple[MessagesRequest, bool | None]] = [] + self.requests: list[MessagesRequest] = [] + self.stream_kwargs: list[dict[str, Any]] = [] + self.events = events or [ + 'event: message_start\ndata: {"type":"message_start"}\n\n', + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', + ] + + def preflight_stream( + self, request: MessagesRequest, *, thinking_enabled: bool | None = None + ) -> None: + self.preflight_calls.append((request, thinking_enabled)) + + async def cleanup(self) -> None: + return None + + async def list_model_ids(self) -> frozenset[str]: + return frozenset({"test-model"}) + + async def stream_response( + self, + request: MessagesRequest, + input_tokens: int = 0, + *, + request_id: str | None = None, + thinking_enabled: bool | None = None, + ) -> AsyncIterator[str]: + self.requests.append(request) + self.stream_kwargs.append( + { + "input_tokens": input_tokens, + "request_id": request_id, + "thinking_enabled": thinking_enabled, + } + ) + for event in self.events: + yield event + + +async def _streaming_body_text(response: StreamingResponse) -> str: + parts: list[str] = [] + async for chunk in response.body_iterator: + if isinstance(chunk, bytes): + parts.append(chunk.decode("utf-8")) + else: + parts.append(str(chunk)) + return "".join(parts) + + +def _json_response_content(response: JSONResponse) -> dict[str, Any]: + content = json.loads(bytes(response.body).decode("utf-8")) + assert isinstance(content, dict) + return content + + +def _trace_events(trace_mock: MagicMock, event: str) -> list[dict[str, Any]]: + return [ + dict(call.kwargs) + for call in trace_mock.call_args_list + if call.kwargs.get("event") == event + ] + + +@pytest.mark.asyncio +async def test_messages_handler_passes_routed_request_and_stream_metadata() -> None: + provider = FakeProvider() + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + messages=[Message(role="user", content="hi")], + ) + + response = await handler.create(request) + assert isinstance(response, StreamingResponse) + + body = await _streaming_body_text(response) + assert "message_start" in body + assert provider.requests[0].model == "test-model" + assert provider.stream_kwargs[0]["input_tokens"] > 0 + assert provider.stream_kwargs[0]["request_id"].startswith("req_") + assert provider.stream_kwargs[0]["thinking_enabled"] is True + assert len(provider.preflight_calls) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [True, False]) +async def test_messages_handler_preflight_invalid_request_stays_http_error( + stream: bool, +) -> None: + class RejectPreflightProvider(FakeProvider): + def preflight_stream( + self, + request: MessagesRequest, + *, + thinking_enabled: bool | None = None, + ) -> None: + raise InvalidRequestError("bad tool shape") + + provider = RejectPreflightProvider() + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + messages=[Message(role="user", content="hi")], + stream=stream, + ) + + with pytest.raises(InvalidRequestError): + await handler.create(request) + + +@pytest.mark.asyncio +async def test_messages_handler_aggregates_provider_stream_when_stream_false() -> None: + provider = FakeProvider( + [ + format_sse_event( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_test", + "type": "message", + "role": "assistant", + "content": [], + "model": "test-model", + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 7, "output_tokens": 1}, + }, + }, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "OK"}, + }, + ), + format_sse_event( + "content_block_stop", {"type": "content_block_stop", "index": 0} + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"input_tokens": 7, "output_tokens": 2}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + ) + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + stream=False, + messages=[Message(role="user", content="hi")], + ) + + response = await handler.create(request) + + assert isinstance(response, JSONResponse) + assert response.headers["content-type"].startswith("application/json") + body = _json_response_content(response) + assert body["id"] == "msg_test" + assert body["type"] == "message" + assert body["role"] == "assistant" + assert body["model"] == "test-model" + assert body["content"] == [{"type": "text", "text": "OK"}] + assert body["stop_reason"] == "end_turn" + assert body["usage"] == {"input_tokens": 7, "output_tokens": 2} + + +@pytest.mark.asyncio +async def test_messages_handler_returns_error_json_for_stream_false_sse_error() -> None: + provider = FakeProvider( + [ + format_sse_event( + "error", + { + "type": "error", + "error": {"type": "api_error", "message": "upstream failed"}, + }, + ) + ] + ) + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + stream=False, + messages=[Message(role="user", content="hi")], + ) + + response = await handler.create(request) + + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + assert response.headers["x-should-retry"] == "false" + body = _json_response_content(response) + assert body["type"] == "error" + assert body["error"] == {"type": "api_error", "message": "upstream failed"} + assert body["request_id"].startswith("req_") + + +@pytest.mark.asyncio +async def test_messages_handler_discards_partial_stream_false_output_on_error() -> None: + provider = FakeProvider( + [ + format_sse_event( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_partial", + "type": "message", + "role": "assistant", + "content": [], + "model": "test-model", + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + }, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "incomplete"}, + }, + ), + format_sse_event( + "error", + { + "type": "error", + "error": { + "type": "overloaded_error", + "message": "provider overloaded", + }, + }, + ), + ] + ) + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + stream=False, + messages=[Message(role="user", content="hi")], + ) + + response = await handler.create(request) + + assert isinstance(response, JSONResponse) + assert response.status_code == 529 + assert response.headers["x-should-retry"] == "false" + body = _json_response_content(response) + assert body["error"] == { + "type": "overloaded_error", + "message": "provider overloaded", + } + assert "content" not in body + + +@pytest.mark.asyncio +async def test_messages_handler_stream_false_provider_exception_keeps_status() -> None: + class FailingProvider(FakeProvider): + async def stream_response( + self, + request: Any, + input_tokens: int = 0, + *, + request_id: str | None = None, + thinking_enabled: bool | None = None, + ) -> AsyncIterator[str]: + self.requests.append(request) + self.stream_kwargs.append( + { + "input_tokens": input_tokens, + "request_id": request_id, + "thinking_enabled": thinking_enabled, + } + ) + raise ExecutionFailure( + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="upstream is busy", + retryable=True, + ) + yield "unreachable" + + provider = FailingProvider() + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + stream=False, + messages=[Message(role="user", content="hi")], + ) + + response = await handler.create(request) + + assert isinstance(response, JSONResponse) + assert response.status_code == 429 + assert response.headers["x-should-retry"] == "false" + body = _json_response_content(response) + assert body["error"] == { + "type": "rate_limit_error", + "message": "upstream is busy", + } + + +@pytest.mark.asyncio +async def test_messages_handler_forces_no_thinking_for_safety_classifier() -> None: + provider = FakeProvider() + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + system=_CLASSIFIER_SYSTEM, + messages=[Message(role="user", content=_CLASSIFIER_USER)], + ) + + with patch("free_claude_code.api.handlers.messages.trace_event") as trace_mock: + response = await handler.create(request) + assert isinstance(response, StreamingResponse) + await _streaming_body_text(response) + + assert provider.preflight_calls[0][1] is False + assert provider.stream_kwargs[0]["thinking_enabled"] is False + assert provider.requests[0].model == "test-model" + assert provider.requests[0].system == _CLASSIFIER_SYSTEM + assert _trace_events( + trace_mock, "free_claude_code.api.optimization.safety_classifier_no_thinking" + ) == [ + { + "stage": "routing", + "event": "free_claude_code.api.optimization.safety_classifier_no_thinking", + "source": "api", + "model": "test-model", + "changed": True, + } + ] + + +@pytest.mark.asyncio +async def test_messages_handler_preserves_thinking_for_non_classifier() -> None: + provider = FakeProvider() + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + system="Explain XML formats.", + messages=[ + Message( + role="user", + content=( + "Explain ... and a tag " + "without making a verdict." + ), + ) + ], + ) + + with patch("free_claude_code.api.handlers.messages.trace_event") as trace_mock: + response = await handler.create(request) + assert isinstance(response, StreamingResponse) + await _streaming_body_text(response) + + assert provider.preflight_calls[0][1] is True + assert provider.stream_kwargs[0]["thinking_enabled"] is True + assert ( + _trace_events( + trace_mock, + "free_claude_code.api.optimization.safety_classifier_no_thinking", + ) + == [] + ) + + +@pytest.mark.asyncio +async def test_messages_handler_keeps_existing_no_thinking_for_classifier() -> None: + provider = FakeProvider() + handler = MessagesHandler(Settings(), provider_resolver=lambda _: provider) + request = MessagesRequest( + model="claude-3-freecc-no-thinking/nvidia_nim/test-model", + max_tokens=100, + system=_CLASSIFIER_SYSTEM, + messages=[Message(role="user", content=_CLASSIFIER_USER)], + ) + + with patch("free_claude_code.api.handlers.messages.trace_event") as trace_mock: + response = await handler.create(request) + assert isinstance(response, StreamingResponse) + await _streaming_body_text(response) + + assert provider.preflight_calls[0][1] is False + assert provider.stream_kwargs[0]["thinking_enabled"] is False + assert _trace_events( + trace_mock, "free_claude_code.api.optimization.safety_classifier_no_thinking" + ) == [ + { + "stage": "routing", + "event": "free_claude_code.api.optimization.safety_classifier_no_thinking", + "source": "api", + "model": "test-model", + "changed": False, + } + ] + + +@pytest.mark.asyncio +async def test_messages_handler_optimization_intercepts_before_provider_execution() -> ( + None +): + provider_resolver = MagicMock() + handler = MessagesHandler(Settings(), provider_resolver=provider_resolver) + request = MessagesRequest( + model="nvidia_nim/test-model", + max_tokens=100, + messages=[Message(role="user", content="quota check")], + ) + optimized = object() + + with patch( + "free_claude_code.api.handlers.messages.try_optimizations", + return_value=optimized, + ): + assert await handler.create(request) is optimized + + provider_resolver.assert_not_called() + + +@pytest.mark.asyncio +async def test_responses_handler_bypasses_message_only_optimizations() -> None: + provider = FakeProvider() + handler = ResponsesHandler(Settings(), provider_resolver=lambda _: provider) + + with patch( + "free_claude_code.api.handlers.messages.try_optimizations", + side_effect=AssertionError("Responses must not use message optimizations"), + ): + response = await handler.create( + OpenAIResponsesRequest( + model="nvidia_nim/test-model", + input="quota check", + ) + ) + + assert isinstance(response, StreamingResponse) + body = await _streaming_body_text(response) + assert "response.completed" in body + assert provider.requests[0].messages[0].content == "quota check" + + +@pytest.mark.asyncio +async def test_responses_handler_does_not_apply_safety_classifier_policy() -> None: + provider = FakeProvider() + handler = ResponsesHandler(Settings(), provider_resolver=lambda _: provider) + + with patch("free_claude_code.api.handlers.messages.trace_event") as trace_mock: + response = await handler.create( + OpenAIResponsesRequest( + model="nvidia_nim/test-model", + input=_CLASSIFIER_USER, + instructions=_CLASSIFIER_SYSTEM, + ) + ) + + assert isinstance(response, StreamingResponse) + await _streaming_body_text(response) + + assert provider.preflight_calls[0][1] is True + assert provider.stream_kwargs[0]["thinking_enabled"] is True + assert ( + _trace_events( + trace_mock, + "free_claude_code.api.optimization.safety_classifier_no_thinking", + ) + == [] + ) + + +def test_token_count_handler_routes_and_counts_tokens() -> None: + handler = TokenCountHandler( + Settings(), + token_counter=lambda messages, system, tools: len(messages) + 41, + ) + + with patch("free_claude_code.api.handlers.token_count.trace_event") as trace: + response = handler.count( + TokenCountRequest( + model="nvidia_nim/test-model", + messages=[Message(role="user", content="hi")], + ), + request_id="req_ingress", + ) + + assert response.input_tokens == 42 + assert all( + call.kwargs["request_id"] == "req_ingress" for call in trace.call_args_list + ) diff --git a/tests/api/test_app_lifespan_and_errors.py b/tests/api/test_app_lifespan_and_errors.py new file mode 100644 index 0000000..6a2e6da --- /dev/null +++ b/tests/api/test_app_lifespan_and_errors.py @@ -0,0 +1,379 @@ +import logging +from pathlib import Path +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from free_claude_code.application.errors import ( + ApplicationUnavailableError, + InvalidRequestError, +) +from free_claude_code.config.settings import Settings +from free_claude_code.messaging.transcription import TranscriptionService +from free_claude_code.providers.nvidia_nim.client import NvidiaNimProvider +from free_claude_code.providers.nvidia_nim.voice import NvidiaNimTranscriber +from free_claude_code.runtime.application import ( + ApplicationRuntime, + startup_failure_message, + warn_if_process_auth_token, +) +from free_claude_code.runtime.asgi import RuntimeASGIApp +from free_claude_code.runtime.bootstrap import _create_transcriber, build_asgi_app +from free_claude_code.runtime.provider_manager import ProviderRuntimeManager +from tests.api.support import create_test_app + + +def _settings(**updates: object) -> Settings: + return Settings().model_copy(update=updates) + + +@pytest.fixture(autouse=True) +def _redirect_fcc_home(monkeypatch, tmp_path): + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + + +def test_warn_if_process_auth_token_logs_warning(monkeypatch): + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "process-token") + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + with patch("free_claude_code.runtime.application.logger.warning") as warning: + warn_if_process_auth_token(Settings.model_construct()) + + warning.assert_called_once() + assert "ANTHROPIC_AUTH_TOKEN" in warning.call_args.args[0] + + +def test_warn_if_process_auth_token_skips_explicit_dotenv_config(monkeypatch, tmp_path): + env_file = tmp_path / ".env" + env_file.write_text("ANTHROPIC_AUTH_TOKEN=\n", encoding="utf-8") + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "process-token") + monkeypatch.setitem(Settings.model_config, "env_file", (env_file,)) + + with patch("free_claude_code.runtime.application.logger.warning") as warning: + warn_if_process_auth_token(Settings.model_construct()) + + warning.assert_not_called() + + +@pytest.mark.asyncio +async def test_runtime_startup_logs_admin_url_without_printed_server_banner(): + settings = _settings( + messaging_platform="none", + host="127.0.0.1", + port=9099, + ) + manager = ProviderRuntimeManager(settings) + runtime = ApplicationRuntime(manager, transcriber=None) + uvicorn_logger = MagicMock() + + with ( + patch("builtins.print") as printed, + patch.object(manager, "validate_configured_models", new=AsyncMock()), + patch.object(manager, "start_model_list_refresh") as start_refresh, + patch.object(manager, "close", new=AsyncMock()), + patch( + "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components", + return_value=None, + ), + patch.object(logging, "getLogger", return_value=uvicorn_logger) as get_logger, + ): + await runtime.start() + await runtime.close() + + printed.assert_not_called() + start_refresh.assert_called_once() + get_logger.assert_any_call("uvicorn.error") + uvicorn_logger.info.assert_called_once_with( + "Admin UI: %s (local-only)", + "http://127.0.0.1:9099/admin", + ) + + +def test_create_app_application_error_handler_returns_anthropic_format(): + app = create_test_app(_settings(log_api_error_tracebacks=False)) + + @app.get("/raise_application") + async def _raise_application(): + raise InvalidRequestError("bad request") + + response = TestClient(app).get("/raise_application") + + assert response.status_code == 400 + body = response.json() + assert body["type"] == "error" + assert body["error"]["type"] == "invalid_request_error" + assert body["request_id"] == response.headers["request-id"] + assert "x-should-retry" not in response.headers + + +def test_application_error_handler_does_not_log_error_message(): + app = create_test_app(_settings(log_api_error_tracebacks=False)) + secret = "provider-upstream-secret-detail" + + @app.get("/raise_application_secret") + async def _raise_application_secret(): + raise InvalidRequestError(secret) + + with patch("free_claude_code.api.app.logger.error") as log_error: + response = TestClient(app).get("/raise_application_secret") + + assert response.status_code == 400 + blob = " ".join( + str(value) for call in log_error.call_args_list for value in call.args + ) + assert secret not in blob + log_error.assert_not_called() + + +def test_create_app_general_exception_handler_returns_correlated_500(): + app = create_test_app(_settings(log_api_error_tracebacks=False)) + + @app.get("/raise_general") + async def _raise_general(): + raise RuntimeError("boom") + + response = TestClient(app, raise_server_exceptions=False).get("/raise_general") + + assert response.status_code == 500 + body = response.json() + assert body["type"] == "error" + assert body["error"]["type"] == "api_error" + assert body["request_id"] == response.headers["request-id"] + + +def test_general_exception_default_log_excludes_exception_message(): + app = create_test_app(_settings(log_api_error_tracebacks=False)) + secret = "user-provided-secret-token-xyzzy" + + @app.get("/raise_secret") + async def _raise_secret(): + raise ValueError(secret) + + with patch("free_claude_code.api.app.logger.error") as log_error: + response = TestClient(app, raise_server_exceptions=False).get("/raise_secret") + + assert response.status_code == 500 + blob = " ".join( + str(value) for call in log_error.call_args_list for value in call.args + ) + assert secret not in blob + assert "ValueError" in blob + + +@pytest.mark.asyncio +async def test_model_validation_failure_does_not_block_runtime_startup(): + settings = _settings(messaging_platform="none") + manager = ProviderRuntimeManager(settings) + runtime = ApplicationRuntime(manager, transcriber=None) + validation = AsyncMock(side_effect=ApplicationUnavailableError("bad model")) + + with ( + patch.object(manager, "validate_configured_models", new=validation), + patch.object(manager, "start_model_list_refresh") as start_refresh, + patch.object(manager, "close", new=AsyncMock()), + patch( + "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components", + return_value=None, + ), + ): + await runtime.start() + await runtime.close() + + validation.assert_awaited_once() + start_refresh.assert_called_once() + + +def test_startup_failure_message_preserves_existing_concise_contract(): + quiet = _settings(log_api_error_tracebacks=False) + verbose = _settings(log_api_error_tracebacks=True) + + assert startup_failure_message(quiet, RuntimeError("secret")) == ( + "Server startup failed: exc_type=RuntimeError" + ) + assert startup_failure_message(verbose, RuntimeError("visible")) == ( + "RuntimeError: visible" + ) + assert ( + startup_failure_message( + quiet, + ApplicationUnavailableError("configured model is unavailable"), + ) + == "configured model is unavailable" + ) + + +@pytest.mark.asyncio +async def test_runtime_asgi_app_starts_and_closes_owner_once(): + runtime = MagicMock(spec=ApplicationRuntime) + runtime.settings = _settings() + runtime.start = AsyncMock() + runtime.close = AsyncMock(return_value=True) + app = RuntimeASGIApp(AsyncMock(), runtime) + received = iter( + [ + {"type": "lifespan.startup"}, + {"type": "lifespan.shutdown"}, + ] + ) + sent: list[dict[str, str]] = [] + + async def receive(): + return next(received) + + async def send(message): + sent.append(message) + + await app({"type": "lifespan"}, receive, send) + + runtime.start.assert_awaited_once() + runtime.close.assert_awaited_once() + assert sent == [ + {"type": "lifespan.startup.complete"}, + {"type": "lifespan.shutdown.complete"}, + ] + + +@pytest.mark.asyncio +async def test_runtime_asgi_app_reports_incomplete_owned_shutdown() -> None: + runtime = MagicMock(spec=ApplicationRuntime) + runtime.settings = _settings() + runtime.start = AsyncMock() + runtime.close = AsyncMock(return_value=False) + app = RuntimeASGIApp(AsyncMock(), runtime) + received = iter( + [ + {"type": "lifespan.startup"}, + {"type": "lifespan.shutdown"}, + ] + ) + sent: list[dict[str, str]] = [] + + async def receive(): + return next(received) + + async def send(message): + sent.append(message) + + await app({"type": "lifespan"}, receive, send) + + assert sent == [ + {"type": "lifespan.startup.complete"}, + {"type": "lifespan.shutdown.failed", "message": ""}, + ] + + +@pytest.mark.asyncio +async def test_runtime_asgi_app_reports_concise_startup_failure(): + runtime = MagicMock(spec=ApplicationRuntime) + runtime.settings = _settings(log_api_error_tracebacks=False) + runtime.start = AsyncMock(side_effect=RuntimeError("secret")) + runtime.close = AsyncMock() + app = RuntimeASGIApp(AsyncMock(), runtime) + sent: list[dict[str, str]] = [] + + async def receive(): + return {"type": "lifespan.startup"} + + async def send(message): + sent.append(message) + + await app({"type": "lifespan"}, receive, send) + + assert sent == [ + { + "type": "lifespan.startup.failed", + "message": "Server startup failed: exc_type=RuntimeError", + } + ] + runtime.close.assert_not_awaited() + + +def test_bootstrap_configures_default_log_and_publishes_only_services(tmp_path): + log_path = tmp_path / "server.log" + settings = _settings() + + with ( + patch( + "free_claude_code.runtime.bootstrap.server_log_path", + return_value=log_path, + ), + patch("free_claude_code.runtime.bootstrap.configure_logging") as configure, + ): + asgi_app = build_asgi_app(settings) + + configure.assert_called_once_with( + Path(log_path), + verbose_third_party=settings.log_raw_api_payloads, + ) + api_app = cast(FastAPI, asgi_app.app) + assert set(api_app.state._state) == {"services"} + + +def test_bootstrap_honors_process_log_file_override(monkeypatch, tmp_path): + log_path = tmp_path / "custom.log" + monkeypatch.setenv("LOG_FILE", str(log_path)) + + with patch("free_claude_code.runtime.bootstrap.configure_logging") as configure: + build_asgi_app(_settings()) + + assert configure.call_args.args[0] == log_path + + +def test_bootstrap_constructs_fresh_runtime_owned_transcribers() -> None: + settings = _settings(voice_note_enabled=True, whisper_device="cpu") + + first = _create_transcriber(settings) + second = _create_transcriber(settings) + + assert isinstance(first, TranscriptionService) + assert isinstance(second, TranscriptionService) + assert first is not second + + +@pytest.mark.asyncio +async def test_bootstrap_constructs_isolated_runtime_resource_graphs() -> None: + settings = _settings( + model="nvidia_nim/test-model", + voice_note_enabled=True, + whisper_device="cpu", + ) + + with patch("free_claude_code.runtime.bootstrap.configure_logging"): + first = build_asgi_app(settings) + second = build_asgi_app(settings) + + first_lease = await first.runtime.provider_manager.acquire() + second_lease = await second.runtime.provider_manager.acquire() + try: + first_provider = first_lease.resolve_provider("nvidia_nim") + second_provider = second_lease.resolve_provider("nvidia_nim") + + assert isinstance(first_provider, NvidiaNimProvider) + assert isinstance(second_provider, NvidiaNimProvider) + assert first_provider._rate_limiter is not second_provider._rate_limiter + assert first.runtime._transcriber is not second.runtime._transcriber + finally: + await first_lease.release() + await second_lease.release() + await first.runtime.close() + await second.runtime.close() + + +def test_bootstrap_selects_nvidia_transcriber_without_loading_riva() -> None: + settings = _settings( + voice_note_enabled=True, + whisper_device="nvidia_nim", + whisper_model="openai/whisper-large-v3", + nvidia_nim_api_key="nvapi-test", + ) + + assert isinstance(_create_transcriber(settings), NvidiaNimTranscriber) + + +def test_bootstrap_disables_transcription_as_one_owned_resource() -> None: + assert _create_transcriber(_settings(voice_note_enabled=False)) is None diff --git a/tests/api/test_app_version.py b/tests/api/test_app_version.py new file mode 100644 index 0000000..7c02e46 --- /dev/null +++ b/tests/api/test_app_version.py @@ -0,0 +1,21 @@ +import warnings + +from fastapi.testclient import TestClient + +from free_claude_code.core.version import package_version +from tests.api.support import create_test_app + + +def test_fastapi_and_openapi_report_installed_package_version() -> None: + app = create_test_app() + + assert app.version == package_version() + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="Duplicate Operation ID", + category=UserWarning, + ) + response = TestClient(app).get("/openapi.json") + assert response.status_code == 200 + assert response.json()["info"]["version"] == package_version() diff --git a/tests/api/test_auth.py b/tests/api/test_auth.py new file mode 100644 index 0000000..6e94f2a --- /dev/null +++ b/tests/api/test_auth.py @@ -0,0 +1,122 @@ +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from free_claude_code.api.dependencies import get_settings +from free_claude_code.config.settings import Settings +from tests.api.support import create_test_app + +app = create_test_app() + + +def test_anthropic_auth_token_required_and_accepts_x_api_key(): + client = TestClient(app) + settings = Settings() + settings.anthropic_auth_token = "s3cr3t" + app.dependency_overrides[get_settings] = lambda: settings + + payload = { + "model": "claude-3-sonnet", + "messages": [{"role": "user", "content": "hello"}], + } + + with patch("free_claude_code.api.routes.get_token_count", return_value=1): + # No header -> 401 + r = client.post("/v1/messages/count_tokens", json=payload) + assert r.status_code == 401 + assert r.headers["request-id"].startswith("req_") + assert "x-should-retry" not in r.headers + + # X-API-Key header -> 200 + r = client.post( + "/v1/messages/count_tokens", json=payload, headers={"X-API-Key": "s3cr3t"} + ) + assert r.status_code == 200 + assert r.json()["input_tokens"] == 1 + + app.dependency_overrides.clear() + + +def test_anthropic_auth_token_accepts_bearer_authorization(): + client = TestClient(app) + settings = Settings() + settings.anthropic_auth_token = "b3artoken" + app.dependency_overrides[get_settings] = lambda: settings + + payload = { + "model": "claude-3-sonnet", + "messages": [{"role": "user", "content": "hello"}], + } + + with patch("free_claude_code.api.routes.get_token_count", return_value=2): + # Authorization Bearer -> 200 + r = client.post( + "/v1/messages/count_tokens", + json=payload, + headers={"Authorization": "Bearer b3artoken"}, + ) + assert r.status_code == 200 + assert r.json()["input_tokens"] == 2 + + app.dependency_overrides.clear() + + +def test_anthropic_auth_token_normalizes_configured_whitespace(): + client = TestClient(app) + settings = Settings() + settings.anthropic_auth_token = " spaced-token \n" + app.dependency_overrides[get_settings] = lambda: settings + + payload = { + "model": "claude-3-sonnet", + "messages": [{"role": "user", "content": "hello"}], + } + + with patch("free_claude_code.api.routes.get_token_count", return_value=3): + r = client.post( + "/v1/messages/count_tokens", + json=payload, + headers={"Authorization": "Bearer spaced-token"}, + ) + assert r.status_code == 200 + assert r.json()["input_tokens"] == 3 + + app.dependency_overrides.clear() + + +def test_anthropic_auth_token_applies_to_models_endpoint(): + client = TestClient(app) + settings = Settings() + settings.anthropic_auth_token = "models-token" + app.dependency_overrides[get_settings] = lambda: settings + + r = client.get("/v1/models") + assert r.status_code == 401 + assert r.headers["x-request-id"] == r.headers["request-id"] + assert "x-should-retry" not in r.headers + + r = client.get("/v1/models", headers={"X-API-Key": "models-token"}) + assert r.status_code == 200 + assert "data" in r.json() + + app.dependency_overrides.clear() + + +def test_root_get_requires_auth_but_root_probes_are_public(): + client = TestClient(app) + settings = Settings() + settings.anthropic_auth_token = "root-token" + app.dependency_overrides[get_settings] = lambda: settings + + response = client.get("/") + assert response.status_code == 401 + + head = client.head("/") + assert head.status_code == 204 + assert head.headers["Allow"] == "GET, HEAD, OPTIONS" + + options = client.options("/") + assert options.status_code == 204 + assert options.headers["Allow"] == "GET, HEAD, OPTIONS" + + app.dependency_overrides.clear() diff --git a/tests/api/test_dependencies.py b/tests/api/test_dependencies.py new file mode 100644 index 0000000..d77830f --- /dev/null +++ b/tests/api/test_dependencies.py @@ -0,0 +1,147 @@ +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException, Request + +from free_claude_code.api.dependencies import ( + get_services, + get_settings, + require_api_key, + resolve_provider, +) +from free_claude_code.api.ports import ApiServices +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.application.ports import RequestRuntimeLease +from free_claude_code.config.settings import Settings +from tests.api.support import create_test_app + + +def _request(*, headers: dict[str, str], token: str) -> tuple[Request, Settings]: + request = Request( + { + "type": "http", + "method": "GET", + "path": "/", + "headers": [ + (key.lower().encode(), value.encode()) for key, value in headers.items() + ], + } + ) + settings = Settings.model_construct(anthropic_auth_token=token) + return request, settings + + +def _lease(*, provider=None, error: Exception | None = None): + lease = MagicMock(spec=RequestRuntimeLease) + lease.is_provider_cached.return_value = False + if error is None: + lease.resolve_provider.return_value = provider or MagicMock() + else: + lease.resolve_provider.side_effect = error + return lease + + +def test_get_services_reads_the_single_app_state_boundary() -> None: + app = create_test_app() + request = Request({"type": "http", "app": app}) + + services = get_services(request) + + assert services is app.state.services + assert isinstance(services, ApiServices) + + +def test_get_settings_reads_current_request_runtime_settings() -> None: + app = create_test_app( + Settings.model_construct( + model="deepseek/test-model", + anthropic_auth_token="", + ) + ) + + assert get_settings(app.state.services).model == "deepseek/test-model" + + +def test_resolve_provider_uses_retained_lease_and_logs_first_initialization() -> None: + provider = MagicMock() + lease = _lease(provider=provider) + + with patch("free_claude_code.api.dependencies.logger.info") as log_info: + result = resolve_provider("nvidia_nim", lease=lease) + + assert result is provider + lease.resolve_provider.assert_called_once_with("nvidia_nim") + log_info.assert_called_once_with("Provider initialized: {}", "nvidia_nim") + + +def test_resolve_provider_skips_initialization_log_for_cached_provider() -> None: + lease = _lease() + lease.is_provider_cached.return_value = True + + with patch("free_claude_code.api.dependencies.logger.info") as log_info: + resolve_provider("nvidia_nim", lease=lease) + + log_info.assert_not_called() + + +def test_resolve_provider_missing_key_preserves_readiness_error() -> None: + lease = _lease( + error=ApplicationUnavailableError( + "OPENROUTER_API_KEY is required. Get one at https://openrouter.ai" + ) + ) + + with pytest.raises(ApplicationUnavailableError) as exc_info: + resolve_provider("open_router", lease=lease) + + assert exc_info.value.status_code == 503 + assert "OPENROUTER_API_KEY" in exc_info.value.message + assert "openrouter.ai" in exc_info.value.message + + +def test_resolve_provider_unrelated_error_is_not_reclassified() -> None: + lease = _lease(error=ValueError("unrelated config")) + + with pytest.raises(ValueError, match="unrelated config"): + resolve_provider("nvidia_nim", lease=lease) + + +def test_require_api_key_allows_when_no_token_configured(): + request, settings = _request(headers={}, token="") + + require_api_key(request, settings) + + +def test_require_api_key_rejects_missing_token(): + request, settings = _request(headers={}, token="secret") + + with pytest.raises(HTTPException) as exc_info: + require_api_key(request, settings) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Missing API key" + + +def test_require_api_key_accepts_x_api_key(): + request, settings = _request(headers={"x-api-key": "secret"}, token="secret") + + require_api_key(request, settings) + + +def test_require_api_key_accepts_bearer_token_and_strips_model_suffix(): + request, settings = _request( + headers={"authorization": "Bearer secret:claude-sonnet"}, + token="secret", + ) + + require_api_key(request, settings) + + +def test_require_api_key_rejects_invalid_token(): + request, settings = _request(headers={"x-api-key": "wrong"}, token="secret") + + with pytest.raises(HTTPException) as exc_info: + require_api_key(request, settings) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Invalid API key" diff --git a/tests/api/test_detection.py b/tests/api/test_detection.py new file mode 100644 index 0000000..800e65b --- /dev/null +++ b/tests/api/test_detection.py @@ -0,0 +1,122 @@ +"""Edge case tests for api/detection.py.""" + +from unittest.mock import patch + +from free_claude_code.api.detection import ( + is_filepath_extraction_request, + is_prefix_detection_request, + is_safety_classifier_request, +) +from free_claude_code.core.anthropic.models import Message, MessagesRequest + + +def _make_request(content: str, **kwargs) -> MessagesRequest: + return MessagesRequest( + model="claude-3-sonnet", + max_tokens=100, + messages=[Message(role="user", content=content)], + **kwargs, + ) + + +class TestIsPrefixDetectionRequest: + def test_output_marker_handling(self): + """Content with Command: but Output: after cmd_start; output has < or \\n\\n.""" + content = " Command:\nls -la\nOutput:\na.txt\nb.txt\n\nmore" + req = _make_request(content) + is_req, cmd = is_prefix_detection_request(req) + assert is_req is True + assert "ls -la" in cmd + + def test_prefix_detection_with_empty_command_section(self): + """Command: at end with no content returns empty command.""" + req = _make_request(" Command: ") + is_req, cmd = is_prefix_detection_request(req) + assert is_req is True + assert cmd == "" + + def test_exception_in_try_returns_false(self): + """Exception in try block (e.g. content slice) returns False, ''.""" + req = _make_request(" Command: x") + + # Return object that raises when sliced - triggers except in is_prefix_detection_request + class BadStr(str): + def __getitem__(self, key): + raise TypeError("bad slice") + + with patch( + "free_claude_code.api.detection.extract_text_from_content", + return_value=BadStr(" Command: x"), + ): + is_req, cmd = is_prefix_detection_request(req) + assert is_req is False + assert cmd == "" + + +class TestIsSafetyClassifierRequest: + _SYSTEM = ( + "You are a security monitor. Respond with yes " + "or no." + ) + _USER = ( + "\nUser: review the repo\n" + "WebFetch https://example.com: fetch\n\n immediately." + ) + + def test_classifier_request_detected(self): + req = _make_request(self._USER, system=self._SYSTEM) + assert is_safety_classifier_request(req) is True + + def test_markers_split_across_system_and_user(self): + req = _make_request( + "\nWebFetch x\n", system=self._SYSTEM + ) + assert is_safety_classifier_request(req) is True + + def test_request_with_tools_is_not_classifier(self): + req = _make_request(self._USER, system=self._SYSTEM, tools=[{"name": "search"}]) + assert is_safety_classifier_request(req) is False + + def test_missing_transcript_marker(self): + req = _make_request(" immediately", system=self._SYSTEM) + assert is_safety_classifier_request(req) is False + + def test_missing_verdict_instruction(self): + req = _make_request( + "\nWebFetch x\n", system="just chatting" + ) + assert is_safety_classifier_request(req) is False + + def test_xml_content_without_verdict_instruction(self): + req = _make_request( + "Explain this format: ... and a tag." + ) + assert is_safety_classifier_request(req) is False + + +class TestIsFilepathExtractionRequest: + def test_output_marker_minus_one_returns_false(self): + """Output: not found after Command: returns False.""" + content = "Command:\nls\nfilepaths" + req = _make_request(content) + is_fp, cmd, out = is_filepath_extraction_request(req) + assert is_fp is False + assert cmd == "" + assert out == "" + + def test_output_has_angle_bracket_splits(self): + """Output containing < is split and first part used.""" + content = "Command:\nls\nOutput:\na.txt b.txt \nfilepaths" + req = _make_request(content) + is_fp, _cmd, out = is_filepath_extraction_request(req) + assert is_fp is True + assert "<" not in out + assert out == "a.txt b.txt" + + def test_output_has_double_newline_splits(self): + """Output containing \\n\\n is split and first part used.""" + content = "Command:\nls\nOutput:\na.txt\nb.txt\n\nmore text\nfilepaths" + req = _make_request(content) + is_fp, _cmd, out = is_filepath_extraction_request(req) + assert is_fp is True + assert "more" not in out diff --git a/tests/api/test_execution_failure_contract.py b/tests/api/test_execution_failure_contract.py new file mode 100644 index 0000000..b2e86e5 --- /dev/null +++ b/tests/api/test_execution_failure_contract.py @@ -0,0 +1,421 @@ +"""Public commit-boundary behavior for canonical execution failures.""" + +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.core.anthropic.streaming import format_sse_event +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from tests.api.support import create_test_app + +_PARTIAL_CONTENT = "PARTIAL_ASSISTANT_CONTENT" + + +class CanonicalFailureProvider: + """Provider double that raises one request-correlated canonical failure.""" + + def __init__( + self, + chunks: list[str], + *, + kind: FailureKind, + status_code: int, + message: str, + retryable: bool, + grouped: bool = False, + ) -> None: + self._chunks = chunks + self._kind = kind + self._status_code = status_code + self._message = message + self._retryable = retryable + self._grouped = grouped + self.preflight_stream = MagicMock() + self.stream_kwargs: list[dict[str, Any]] = [] + + async def stream_response( + self, + _request: object, + **kwargs: Any, + ) -> AsyncIterator[str]: + self.stream_kwargs.append(kwargs) + for chunk in self._chunks: + yield chunk + request_id = kwargs["request_id"] + failure = ExecutionFailure( + kind=self._kind, + status_code=self._status_code, + message=f"{self._message}\n\nRequest ID: {request_id}", + retryable=self._retryable, + ) + if self._grouped: + raise ExceptionGroup( + "provider stream and cleanup failed", + [ + RuntimeError("cleanup failed"), + ExceptionGroup("provider request failed", [failure]), + ], + ) + raise failure + + +def _messages_payload(*, stream: bool) -> dict[str, object]: + return { + "model": "nvidia_nim/test-model", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 32, + "stream": stream, + } + + +def _responses_payload() -> dict[str, object]: + return { + "model": "nvidia_nim/test-model", + "input": "Hello", + "max_output_tokens": 32, + } + + +def _partial_anthropic_stream(*, close_block: bool) -> list[str]: + chunks = [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": _PARTIAL_CONTENT}, + }, + ), + ] + if close_block: + chunks.append( + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ) + ) + return chunks + + +def _client_for(provider: CanonicalFailureProvider): + app = create_test_app() + return ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app), + ) + + +def _terminal_trace(trace_mock: MagicMock) -> dict[str, Any]: + return dict( + next( + call.kwargs + for call in trace_mock.call_args_list + if call.kwargs.get("event") + == "free_claude_code.api.response.terminal_execution_error" + ) + ) + + +def _grouped_rate_limit_provider(chunks: list[str]) -> CanonicalFailureProvider: + return CanonicalFailureProvider( + chunks, + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="upstream is busy", + retryable=True, + grouped=True, + ) + + +@pytest.mark.parametrize( + ("path", "payload", "expected_type"), + [ + ("/v1/messages", _messages_payload(stream=True), "rate_limit_error"), + ("/v1/responses", _responses_payload(), "rate_limit_error"), + ], +) +def test_grouped_pre_start_execution_failure_keeps_canonical_wire_error( + path: str, + payload: dict[str, object], + expected_type: str, +) -> None: + provider = _grouped_rate_limit_provider([]) + resolver_patch, client = _client_for(provider) + + with ( + resolver_patch, + patch("free_claude_code.api.response_streams.trace_event") as trace_mock, + client, + ): + response = client.post(path, json=payload) + + request_id = response.headers["request-id"] + assert response.status_code == 429 + assert response.headers["x-should-retry"] == "false" + error = response.json()["error"] + assert error["type"] == expected_type + assert error["message"] == f"upstream is busy\n\nRequest ID: {request_id}" + trace = _terminal_trace(trace_mock) + assert trace["status_code"] == 429 + assert trace["error_type"] == "rate_limit_error" + assert trace["exc_type"] == "ExecutionFailure" + assert trace["failure_kind"] == "rate_limit" + + +@pytest.mark.parametrize("path", ["/v1/messages", "/v1/responses"]) +def test_grouped_post_start_execution_failure_keeps_canonical_terminal_event( + path: str, +) -> None: + provider = _grouped_rate_limit_provider(_partial_anthropic_stream(close_block=True)) + payload = ( + _messages_payload(stream=True) + if path == "/v1/messages" + else _responses_payload() + ) + resolver_patch, client = _client_for(provider) + + with ( + resolver_patch, + patch("free_claude_code.api.response_streams.trace_event") as trace_mock, + client, + ): + response = client.post(path, json=payload) + + request_id = response.headers["request-id"] + events = parse_sse_text(response.text) + if path == "/v1/messages": + assert events[-1].event == "error" + error = events[-1].data["error"] + else: + assert events[-1].event == "response.failed" + error = events[-1].data["response"]["error"] + assert response.status_code == 200 + assert error["type"] == "rate_limit_error" + assert error["message"] == f"upstream is busy\n\nRequest ID: {request_id}" + assert _terminal_trace(trace_mock)["failure_kind"] == "rate_limit" + + +def test_grouped_stream_false_execution_failure_discards_partial_content() -> None: + provider = _grouped_rate_limit_provider( + _partial_anthropic_stream(close_block=False) + ) + resolver_patch, client = _client_for(provider) + + with ( + resolver_patch, + patch("free_claude_code.api.response_streams.trace_event") as trace_mock, + client, + ): + response = client.post("/v1/messages", json=_messages_payload(stream=False)) + + request_id = response.headers["request-id"] + assert response.status_code == 429 + assert response.headers["x-should-retry"] == "false" + assert response.json()["error"] == { + "type": "rate_limit_error", + "message": f"upstream is busy\n\nRequest ID: {request_id}", + } + assert _PARTIAL_CONTENT not in response.text + trace = _terminal_trace(trace_mock) + assert trace["status_code"] == 429 + assert trace["error_type"] == "rate_limit_error" + assert trace["exc_type"] == "ExecutionFailure" + assert trace["failure_kind"] == "rate_limit" + + +def test_messages_pre_start_execution_failure_is_correlated_terminal_json() -> None: + provider = CanonicalFailureProvider( + [], + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="upstream is busy", + retryable=True, + ) + resolver_patch, client = _client_for(provider) + + with resolver_patch, client: + response = client.post("/v1/messages", json=_messages_payload(stream=True)) + + request_id = response.headers["request-id"] + assert response.status_code == 429 + assert response.headers["content-type"].startswith("application/json") + assert response.headers["x-should-retry"] == "false" + assert "x-request-id" not in response.headers + assert response.json() == { + "type": "error", + "error": { + "type": "rate_limit_error", + "message": f"upstream is busy\n\nRequest ID: {request_id}", + }, + "request_id": request_id, + } + assert provider.stream_kwargs[0]["request_id"] == request_id + + +def test_responses_pre_start_execution_failure_is_correlated_terminal_json() -> None: + provider = CanonicalFailureProvider( + [], + kind=FailureKind.OVERLOADED, + status_code=529, + message="provider overloaded", + retryable=True, + ) + resolver_patch, client = _client_for(provider) + + with resolver_patch, client: + response = client.post("/v1/responses", json=_responses_payload()) + + request_id = response.headers["request-id"] + assert response.status_code == 529 + assert response.headers["content-type"].startswith("application/json") + assert response.headers["x-should-retry"] == "false" + assert response.headers["x-request-id"] == request_id + assert response.json() == { + "error": { + "message": f"provider overloaded\n\nRequest ID: {request_id}", + "type": "overloaded_error", + "param": None, + "code": None, + } + } + assert provider.stream_kwargs[0]["request_id"] == request_id + + +def test_messages_post_start_execution_failure_follows_closed_block() -> None: + provider = CanonicalFailureProvider( + _partial_anthropic_stream(close_block=True), + kind=FailureKind.OVERLOADED, + status_code=529, + message="provider overloaded", + retryable=True, + ) + resolver_patch, client = _client_for(provider) + + with ( + resolver_patch, + patch("free_claude_code.api.response_streams.trace_event") as trace_mock, + client, + ): + response = client.post("/v1/messages", json=_messages_payload(stream=True)) + + request_id = response.headers["request-id"] + events = parse_sse_text(response.text) + assert response.status_code == 200 + assert "x-should-retry" not in response.headers + assert [event.event for event in events] == [ + "message_start", + "content_block_start", + "content_block_delta", + "content_block_stop", + "error", + ] + assert events[-1].data["error"] == { + "type": "overloaded_error", + "message": f"provider overloaded\n\nRequest ID: {request_id}", + } + assert "message_stop" not in response.text + assert _terminal_trace(trace_mock) == { + "stage": "egress", + "event": "free_claude_code.api.response.terminal_execution_error", + "source": "api", + "wire_api": "messages", + "request_id": request_id, + "status_code": 529, + "error_type": "overloaded_error", + "client_should_retry": False, + "exc_type": "ExecutionFailure", + "failure_kind": "overloaded", + "provider_retryable": True, + } + + +def test_responses_post_start_execution_failure_retains_id_after_block_close() -> None: + provider = CanonicalFailureProvider( + _partial_anthropic_stream(close_block=True), + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="upstream is busy", + retryable=True, + ) + resolver_patch, client = _client_for(provider) + + with ( + resolver_patch, + patch("free_claude_code.api.response_streams.trace_event") as trace_mock, + client, + ): + response = client.post("/v1/responses", json=_responses_payload()) + + request_id = response.headers["request-id"] + events = parse_sse_text(response.text) + event_names = [event.event for event in events] + created = events[0].data["response"] + failed = events[-1].data["response"] + assert response.status_code == 200 + assert response.headers["x-request-id"] == request_id + assert "x-should-retry" not in response.headers + assert event_names[0] == "response.created" + assert "response.output_item.done" in event_names + assert event_names.index("response.output_item.done") < event_names.index( + "response.failed" + ) + assert event_names[-1] == "response.failed" + assert failed["id"] == created["id"] + assert failed["status"] == "failed" + assert failed["error"] == { + "message": f"upstream is busy\n\nRequest ID: {request_id}", + "type": "rate_limit_error", + "param": None, + "code": None, + } + assert _terminal_trace(trace_mock) == { + "stage": "egress", + "event": "free_claude_code.api.response.terminal_execution_error", + "source": "api", + "wire_api": "responses", + "request_id": request_id, + "status_code": 429, + "error_type": "rate_limit_error", + "client_should_retry": False, + "exc_type": "ExecutionFailure", + "failure_kind": "rate_limit", + "provider_retryable": True, + } + + +def test_messages_stream_false_discards_partial_content_on_execution_failure() -> None: + provider = CanonicalFailureProvider( + _partial_anthropic_stream(close_block=False), + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="upstream is busy", + retryable=True, + ) + resolver_patch, client = _client_for(provider) + + with resolver_patch, client: + response = client.post("/v1/messages", json=_messages_payload(stream=False)) + + request_id = response.headers["request-id"] + assert response.status_code == 429 + assert response.headers["content-type"].startswith("application/json") + assert response.headers["x-should-retry"] == "false" + assert response.json()["request_id"] == request_id + assert response.json()["error"] == { + "type": "rate_limit_error", + "message": f"upstream is busy\n\nRequest ID: {request_id}", + } + assert _PARTIAL_CONTENT not in response.text diff --git a/tests/api/test_model_listing.py b/tests/api/test_model_listing.py new file mode 100644 index 0000000..7fe8ae7 --- /dev/null +++ b/tests/api/test_model_listing.py @@ -0,0 +1,144 @@ +from fastapi.testclient import TestClient + +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.settings import Settings +from tests.api.support import create_test_app, provider_manager_for_app + + +def _settings( + *, + model: str = "deepseek/deepseek-chat", + model_opus: str | None = "open_router/anthropic/claude-opus", + model_haiku: str | None = "deepseek/deepseek-chat", +) -> Settings: + return Settings.model_construct( + model=model, + model_opus=model_opus, + model_sonnet=None, + model_haiku=model_haiku, + anthropic_auth_token="", + ) + + +def _cache_models(app, provider_id: str, *model_ids: str) -> None: + provider_manager_for_app(app).cache_model_infos( + provider_id, + {ProviderModelInfo(model_id) for model_id in model_ids}, + ) + + +def test_models_list_includes_configured_refs_cached_provider_models_and_aliases(): + app = create_test_app(_settings()) + _cache_models(app, "deepseek", "deepseek-chat") + _cache_models( + app, + "open_router", + "meta/llama-3.3", + "anthropic/claude-opus", + ) + + response = TestClient(app).get("/v1/models") + + assert response.status_code == 200 + data = response.json() + ids = [item["id"] for item in data["data"]] + assert ids[:6] == [ + "anthropic/deepseek/deepseek-chat", + "claude-3-freecc-no-thinking/deepseek/deepseek-chat", + "anthropic/open_router/anthropic/claude-opus", + "claude-3-freecc-no-thinking/open_router/anthropic/claude-opus", + "anthropic/open_router/meta/llama-3.3", + "claude-3-freecc-no-thinking/open_router/meta/llama-3.3", + ] + assert ids.count("anthropic/deepseek/deepseek-chat") == 1 + assert ids.count("anthropic/open_router/anthropic/claude-opus") == 1 + display_names = {item["id"]: item["display_name"] for item in data["data"]} + assert ( + display_names["anthropic/open_router/meta/llama-3.3"] + == "open_router/meta/llama-3.3" + ) + assert ( + display_names["claude-3-freecc-no-thinking/open_router/meta/llama-3.3"] + == "open_router/meta/llama-3.3 (no thinking)" + ) + assert "claude-sonnet-4-20250514" in ids + assert data["first_id"] == ids[0] + assert data["last_id"] == ids[-1] + assert data["has_more"] is False + + +def test_models_list_uses_thinking_metadata_for_cached_models(): + app = create_test_app(_settings(model_opus=None)) + manager = provider_manager_for_app(app) + _cache_models(app, "deepseek", "deepseek-chat") + manager.cache_model_infos( + "open_router", + { + ProviderModelInfo("reasoning-model", supports_thinking=True), + ProviderModelInfo("plain-model", supports_thinking=False), + }, + ) + + response = TestClient(app).get("/v1/models") + + assert response.status_code == 200 + ids = [item["id"] for item in response.json()["data"]] + assert "anthropic/open_router/reasoning-model" in ids + assert "claude-3-freecc-no-thinking/open_router/reasoning-model" in ids + assert "anthropic/open_router/plain-model" not in ids + assert "claude-3-freecc-no-thinking/open_router/plain-model" in ids + + +def test_models_list_uses_cached_metadata_for_configured_refs(): + app = create_test_app( + _settings( + model="open_router/plain-model", + model_opus=None, + model_haiku=None, + ) + ) + provider_manager_for_app(app).cache_model_infos( + "open_router", + {ProviderModelInfo("plain-model", supports_thinking=False)}, + ) + + response = TestClient(app).get("/v1/models") + + ids = [item["id"] for item in response.json()["data"]] + assert "anthropic/open_router/plain-model" not in ids + assert ids[0] == "claude-3-freecc-no-thinking/open_router/plain-model" + + +def test_models_list_includes_cached_wafer_models(): + app = create_test_app( + _settings( + model="wafer/DeepSeek-V4-Pro", + model_opus=None, + model_haiku=None, + ) + ) + _cache_models(app, "wafer", "DeepSeek-V4-Pro", "MiniMax-M2.7") + + response = TestClient(app).get("/v1/models") + + ids = [item["id"] for item in response.json()["data"]] + assert "anthropic/wafer/DeepSeek-V4-Pro" in ids + assert "claude-3-freecc-no-thinking/wafer/DeepSeek-V4-Pro" in ids + assert "anthropic/wafer/MiniMax-M2.7" in ids + assert "claude-3-freecc-no-thinking/wafer/MiniMax-M2.7" in ids + + +def test_models_list_works_with_empty_discovery_catalog(): + app = create_test_app(_settings()) + + response = TestClient(app).get("/v1/models") + + assert response.status_code == 200 + ids = [item["id"] for item in response.json()["data"]] + assert ids[:4] == [ + "anthropic/deepseek/deepseek-chat", + "claude-3-freecc-no-thinking/deepseek/deepseek-chat", + "anthropic/open_router/anthropic/claude-opus", + "claude-3-freecc-no-thinking/open_router/anthropic/claude-opus", + ] + assert "claude-sonnet-4-20250514" in ids diff --git a/tests/api/test_openai_responses.py b/tests/api/test_openai_responses.py new file mode 100644 index 0000000..7dd7a20 --- /dev/null +++ b/tests/api/test_openai_responses.py @@ -0,0 +1,964 @@ +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.core.anthropic.streaming import format_sse_event +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from tests.api.support import create_test_app + + +class FakeProvider: + def __init__(self, chunks: list[str]) -> None: + self.chunks = chunks + self.preflight_stream = MagicMock() + self.requests: list[Any] = [] + self.stream_kwargs: list[dict[str, Any]] = [] + + async def stream_response(self, request_data, **_kwargs): + self.requests.append(request_data) + self.stream_kwargs.append(_kwargs) + for chunk in self.chunks: + yield chunk + + +class PreStartFailingProvider(FakeProvider): + def __init__(self) -> None: + super().__init__([]) + + async def stream_response(self, request_data, **_kwargs): + self.requests.append(request_data) + self.stream_kwargs.append(_kwargs) + raise ExecutionFailure( + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="upstream is busy", + retryable=True, + ) + yield "unreachable" + + +class PostStartFailingProvider(FakeProvider): + def __init__(self) -> None: + super().__init__([format_sse_event("message_start", {"type": "message_start"})]) + + async def stream_response(self, request_data, **_kwargs): + self.requests.append(request_data) + self.stream_kwargs.append(_kwargs) + for chunk in self.chunks: + yield chunk + raise RuntimeError("socket closed") + + +@pytest.fixture +def responses_client(): + provider = FakeProvider(_anthropic_text_stream("Hello from provider")) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + yield client, provider + + +def test_responses_probe_endpoints_return_204( + responses_client: tuple[TestClient, FakeProvider], +) -> None: + client, _provider = responses_client + + assert client.head("/v1/responses").status_code == 204 + assert client.options("/v1/responses").status_code == 204 + + +def test_create_response_stream_routes_through_provider( + responses_client: tuple[TestClient, FakeProvider], +) -> None: + client, provider = responses_client + + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Hello", + "max_output_tokens": 32, + }, + ) + + assert response.status_code == 200 + assert "text/event-stream" in response.headers["content-type"] + assert response.headers["x-request-id"] == response.headers["request-id"] + events = parse_sse_text(response.text) + assert events[0].event == "response.created" + assert events[-1].event == "response.completed" + assert events[-1].data["response"]["output"][0]["content"][0]["text"] == ( + "Hello from provider" + ) + assert provider.preflight_stream.called + routed = provider.requests[0] + assert routed.model == "test-model" + assert routed.messages[0].role == "user" + assert routed.messages[0].content == "Hello" + assert routed.max_tokens == 32 + assert provider.stream_kwargs[0]["request_id"] == response.headers["request-id"] + + +def test_create_response_preflight_rejection_stays_an_ordinary_http_error() -> None: + provider = FakeProvider(_anthropic_text_stream("unused")) + provider.preflight_stream.side_effect = InvalidRequestError("bad tool shape") + app = create_test_app() + + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={"model": "nvidia_nim/test-model", "input": "Hello"}, + ) + + assert response.status_code == 400 + assert response.json()["error"] == { + "message": "bad tool shape", + "type": "invalid_request_error", + "param": None, + "code": None, + } + assert "x-should-retry" not in response.headers + assert provider.requests == [] + + +def test_create_response_accepts_unknown_top_level_extensions( + responses_client: tuple[TestClient, FakeProvider], +) -> None: + client, provider = responses_client + + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Hello", + "provider_extension": {"enabled": True}, + }, + ) + + assert response.status_code == 200 + assert provider.requests[0].messages[0].content == "Hello" + + +def test_create_response_pre_start_provider_error_returns_openai_error() -> None: + provider = PreStartFailingProvider() + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + patch("free_claude_code.api.response_streams.trace_event") as trace, + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Hello", + }, + ) + + assert response.status_code == 429 + assert response.headers["x-should-retry"] == "false" + assert response.headers["x-request-id"] == response.headers["request-id"] + payload = response.json() + assert payload["error"]["type"] == "rate_limit_error" + assert payload["error"]["message"] == "upstream is busy" + request_id = response.headers["request-id"] + assert provider.stream_kwargs[0]["request_id"] == request_id + terminal_trace = next( + call.kwargs + for call in trace.call_args_list + if call.kwargs.get("event") + == "free_claude_code.api.response.terminal_execution_error" + ) + assert terminal_trace["wire_api"] == "responses" + assert terminal_trace["request_id"] == request_id + assert terminal_trace["status_code"] == 429 + assert terminal_trace["error_type"] == "rate_limit_error" + assert terminal_trace["client_should_retry"] is False + assert terminal_trace["failure_kind"] == "rate_limit" + assert terminal_trace["provider_retryable"] is True + + +def test_create_response_post_start_failure_preserves_response_id() -> None: + provider = PostStartFailingProvider() + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Hello", + }, + ) + + assert response.status_code == 200 + events = parse_sse_text(response.text) + assert [event.event for event in events] == ["response.created", "response.failed"] + assert events[-1].data["response"]["id"] == events[0].data["response"]["id"] + assert events[-1].data["response"]["status"] == "failed" + assert events[-1].data["response"]["error"]["message"] == "socket closed" + + +def test_create_response_stream_bypasses_local_message_optimizations() -> None: + provider = FakeProvider(_anthropic_text_stream("Provider response")) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + patch( + "free_claude_code.api.handlers.messages.try_optimizations", + side_effect=AssertionError("Responses must not use message optimizations"), + ), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "quota check", + }, + ) + + assert response.status_code == 200 + completed = parse_sse_text(response.text)[-1].data["response"] + assert completed["output"][0]["content"][0]["text"] == "Provider response" + assert provider.requests[0].messages[0].content == "quota check" + + +def test_create_response_stream_false_returns_openai_error( + responses_client: tuple[TestClient, FakeProvider], +) -> None: + client, provider = responses_client + + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Hello", + "stream": False, + }, + ) + + assert response.status_code == 400 + payload = response.json() + assert payload["error"]["type"] == "invalid_request_error" + assert "streaming only" in payload["error"]["message"] + assert provider.requests == [] + + +def test_create_response_stream_preserves_interleaved_reasoning_order() -> None: + provider = FakeProvider(_anthropic_interleaved_reasoning_stream()) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Use reasoning and tools", + "stream": True, + "tools": [ + { + "type": "function", + "name": "echo", + "parameters": {"type": "object", "properties": {}}, + } + ], + }, + ) + + assert response.status_code == 200 + events = parse_sse_text(response.text) + assert "response.reasoning_text.delta" in [event.event for event in events] + completed = events[-1].data["response"] + assert [item["type"] for item in completed["output"]] == [ + "reasoning", + "message", + "function_call", + "reasoning", + "message", + ] + assert completed["output"][0]["content"][0]["text"] == "first thought" + assert completed["output"][1]["content"][0]["text"] == "first answer" + assert completed["output"][2]["arguments"] == '{"value":"FCC"}' + assert completed["output"][3]["content"][0]["text"] == "second thought" + assert completed["output"][4]["content"][0]["text"] == "final answer" + + +def test_create_response_tool_stream_emits_function_call() -> None: + provider = FakeProvider(_anthropic_tool_stream()) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Use echo", + "stream": True, + "tools": [ + { + "type": "function", + "name": "echo", + "parameters": {"type": "object", "properties": {}}, + } + ], + }, + ) + + assert response.status_code == 200 + events = parse_sse_text(response.text) + completed = events[-1].data["response"] + call = completed["output"][0] + assert call["type"] == "function_call" + assert call["call_id"] == "toolu_1" + assert call["arguments"] == '{"value":"FCC"}' + + +def test_create_response_malformed_provider_function_call_fails_stream() -> None: + provider = FakeProvider( + _anthropic_tool_stream(partial_json='{"value":"FCC" "bad"}') + ) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Use echo", + "stream": True, + "tools": [ + { + "type": "function", + "name": "echo", + "parameters": {"type": "object", "properties": {}}, + } + ], + }, + ) + + assert response.status_code == 200 + events = parse_sse_text(response.text) + assert events[-1].event == "response.failed" + failed = events[-1].data["response"] + assert failed["status"] == "failed" + assert failed["output"] == [] + assert "replay-unsafe Responses output" in failed["error"]["message"] + + +def test_create_response_accepts_codex_namespace_tool_request() -> None: + provider = FakeProvider(_anthropic_tool_stream(tool_name="mcp__node_repl__js")) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Use JS", + "stream": True, + "tools": [ + {"type": "web_search", "external_web_access": True}, + {"type": "image_generation", "output_format": "png"}, + { + "type": "namespace", + "name": "mcp__node_repl", + "tools": [ + { + "type": "function", + "name": "js", + "parameters": { + "type": "object", + "properties": {"code": {"type": "string"}}, + }, + } + ], + }, + ], + }, + ) + + assert response.status_code == 200 + routed = provider.requests[0] + assert [tool.name for tool in routed.tools] == ["mcp__node_repl__js"] + completed = parse_sse_text(response.text)[-1].data["response"] + call = completed["output"][0] + assert call["namespace"] == "mcp__node_repl" + assert call["name"] == "js" + + +def test_create_response_accepts_codex_custom_tool_request() -> None: + provider = FakeProvider( + _anthropic_tool_stream( + tool_name="apply_patch", + partial_json='{"input":"*** Begin Patch"}', + ) + ) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Use apply_patch", + "stream": True, + "tools": [ + { + "type": "custom", + "name": "apply_patch", + "description": "Apply repo patches", + "format": {"type": "text"}, + } + ], + "tool_choice": {"type": "custom", "name": "apply_patch"}, + }, + ) + + assert response.status_code == 200 + routed = provider.requests[0] + assert [tool.name for tool in routed.tools] == ["apply_patch"] + assert routed.tool_choice == {"type": "tool", "name": "apply_patch"} + events = parse_sse_text(response.text) + assert "response.custom_tool_call_input.delta" in [event.event for event in events] + completed = events[-1].data["response"] + call = completed["output"][0] + assert call["type"] == "custom_tool_call" + assert call["name"] == "apply_patch" + assert call["input"] == "*** Begin Patch" + + +def test_create_response_stream_provider_error_returns_response_failed() -> None: + provider = FakeProvider( + [ + format_sse_event( + "error", + { + "type": "error", + "error": { + "type": "api_error", + "message": "provider failed", + }, + }, + ) + ] + ) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Hello", + "stream": True, + }, + ) + + assert response.status_code == 200 + events = parse_sse_text(response.text) + assert [event.event for event in events] == ["response.created", "response.failed"] + failed = events[-1].data["response"] + assert failed["id"] == events[0].data["response"]["id"] + assert failed["status"] == "failed" + assert failed["error"] == { + "message": "provider failed", + "type": "api_error", + "param": None, + "code": None, + } + + +def test_create_response_replays_prior_reasoning_as_reasoning_content() -> None: + provider = FakeProvider(_anthropic_text_stream("done")) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": [ + { + "id": "rs_1", + "type": "reasoning", + "summary": [], + "content": [ + {"type": "reasoning_text", "text": "Need the tool."} + ], + }, + { + "type": "function_call", + "call_id": "call_1", + "name": "echo", + "arguments": "{}", + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "ok", + }, + { + "id": "rs_2", + "type": "reasoning", + "summary": [ + {"type": "summary_text", "text": "Use the result."} + ], + }, + {"role": "user", "content": "continue"}, + ], + "stream": True, + }, + ) + + assert response.status_code == 200 + routed = provider.requests[0] + assert routed.messages[0].role == "assistant" + assert routed.messages[0].reasoning_content == "Need the tool." + assert routed.messages[0].content[0].type == "tool_use" + assert routed.messages[1].role == "user" + assert routed.messages[1].content[0].type == "tool_result" + assert routed.messages[2].role == "assistant" + assert routed.messages[2].content == "" + assert routed.messages[2].reasoning_content == "Use the result." + assert routed.messages[3].role == "user" + assert routed.messages[3].content == "continue" + + +def test_create_response_quarantines_malformed_prior_function_call() -> None: + provider = FakeProvider(_anthropic_text_stream("done")) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": [ + {"role": "user", "content": "hello"}, + { + "type": "function_call", + "call_id": "call_bad", + "name": "echo", + "arguments": "{", + }, + { + "type": "function_call_output", + "call_id": "call_bad", + "output": "stale output", + }, + {"role": "user", "content": "continue"}, + ], + "stream": True, + }, + ) + + assert response.status_code == 200 + routed = provider.requests[0] + assert [message.role for message in routed.messages] == ["user", "user"] + assert routed.messages[0].content == "hello" + assert routed.messages[1].content == "continue" + completed = parse_sse_text(response.text)[-1].data["response"] + assert completed["output"][0]["content"][0]["text"] == "done" + + +@pytest.mark.parametrize( + ("reasoning", "expected_type", "expected_enabled"), + [ + ({"effort": "none"}, "disabled", False), + ({"effort": "low"}, "enabled", True), + ], +) +def test_create_response_maps_reasoning_effort_to_thinking_request( + reasoning: dict[str, str], + expected_type: str, + expected_enabled: bool, +) -> None: + provider = FakeProvider(_anthropic_text_stream("done")) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Hello", + "stream": True, + "reasoning": reasoning, + }, + ) + + assert response.status_code == 200 + thinking = provider.requests[0].thinking + assert thinking.type == expected_type + assert thinking.enabled is expected_enabled + + +def test_create_response_maps_redacted_thinking_to_encrypted_reasoning() -> None: + provider = FakeProvider(_anthropic_redacted_thinking_stream()) + app = create_test_app() + with ( + patch("free_claude_code.api.routes.resolve_provider", return_value=provider), + TestClient(app) as client, + ): + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Continue", + "stream": True, + }, + ) + + assert response.status_code == 200 + completed = parse_sse_text(response.text)[-1].data["response"] + assert completed["output"] == [ + { + "id": completed["output"][0]["id"], + "type": "reasoning", + "status": "completed", + "summary": [], + "encrypted_content": "opaque-redacted", + } + ] + assert "content" not in completed["output"][0] + + +def test_create_response_unsupported_tool_returns_openai_error( + responses_client: tuple[TestClient, FakeProvider], +) -> None: + client, _provider = responses_client + + response = client.post( + "/v1/responses", + json={ + "model": "nvidia_nim/test-model", + "input": "Hello", + "tools": [{"type": "web_search_preview"}], + }, + ) + + assert response.status_code == 400 + payload = response.json() + assert payload["error"]["type"] == "invalid_request_error" + assert "Unsupported Responses tool type" in payload["error"]["message"] + + +def _anthropic_text_stream(text: str) -> list[str]: + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": text}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"input_tokens": 3, "output_tokens": 4}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + + +def _anthropic_tool_stream( + tool_name: str = "echo", partial_json: str = '{"value":"FCC"}' +) -> list[str]: + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "toolu_1", + "name": tool_name, + "input": {}, + }, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "input_json_delta", + "partial_json": partial_json, + }, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "tool_use", "stop_sequence": None}, + "usage": {"input_tokens": 3, "output_tokens": 4}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + + +def _anthropic_reasoning_text_stream() -> list[str]: + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "thinking_delta", + "thinking": "provider reasoning", + }, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 1, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "text_delta", "text": "provider answer"}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 1}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"input_tokens": 3, "output_tokens": 4}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + + +def _anthropic_interleaved_reasoning_stream() -> list[str]: + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "first thought"}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 1, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "text_delta", "text": "first answer"}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 1}, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 2, + "content_block": { + "type": "tool_use", + "id": "toolu_1", + "name": "echo", + "input": {}, + }, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 2, + "delta": { + "type": "input_json_delta", + "partial_json": '{"value":"FCC"}', + }, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 2}, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 3, + "content_block": {"type": "thinking", "thinking": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 3, + "delta": {"type": "thinking_delta", "thinking": "second thought"}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 3}, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 4, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 4, + "delta": {"type": "text_delta", "text": "final answer"}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 4}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"input_tokens": 3, "output_tokens": 4}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + + +def _anthropic_redacted_thinking_stream() -> list[str]: + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "redacted_thinking", + "data": "opaque-redacted", + }, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"input_tokens": 3, "output_tokens": 4}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] diff --git a/tests/api/test_optimization_handlers.py b/tests/api/test_optimization_handlers.py new file mode 100644 index 0000000..b4d7828 --- /dev/null +++ b/tests/api/test_optimization_handlers.py @@ -0,0 +1,235 @@ +"""Tests for api/optimization_handlers.py.""" + +from unittest.mock import patch + +from free_claude_code.api.optimization_handlers import ( + try_filepath_mock, + try_optimizations, + try_prefix_detection, + try_quota_mock, + try_suggestion_skip, + try_title_skip, +) +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic.models import ( + ContentBlockText, + Message, + MessagesRequest, +) + + +def _make_request( + messages_content: str, max_tokens: int | None = None +) -> MessagesRequest: + """Create a MessagesRequest with a single user message.""" + return MessagesRequest( + model="claude-3-sonnet", + max_tokens=max_tokens if max_tokens is not None else 100, + messages=[Message(role="user", content=messages_content)], + ) + + +class TestTryPrefixDetection: + def test_disabled_returns_none(self): + settings = Settings() + settings.fast_prefix_detection = False + req = _make_request("x") + with patch( + "free_claude_code.api.optimization_handlers.is_prefix_detection_request", + return_value=(True, "/ask"), + ): + assert try_prefix_detection(req, settings) is None + + def test_enabled_and_match_returns_response(self): + settings = Settings() + settings.fast_prefix_detection = True + req = _make_request("x") + with ( + patch( + "free_claude_code.api.optimization_handlers.is_prefix_detection_request", + return_value=(True, "/ask"), + ), + patch( + "free_claude_code.api.optimization_handlers.extract_command_prefix", + return_value="/ask", + ), + patch( + "free_claude_code.api.optimization_handlers.logger.info" + ) as mock_log_info, + ): + result = try_prefix_detection(req, settings) + assert result is not None + block = result.content[0] + assert isinstance(block, ContentBlockText) + assert block.text == "/ask" + mock_log_info.assert_called_once_with( + "Optimization: Fast prefix detection request" + ) + + def test_enabled_but_no_match_returns_none(self): + settings = Settings() + settings.fast_prefix_detection = True + req = _make_request("x") + with patch( + "free_claude_code.api.optimization_handlers.is_prefix_detection_request", + return_value=(False, ""), + ): + assert try_prefix_detection(req, settings) is None + + +class TestTryQuotaMock: + def test_disabled_returns_none(self): + settings = Settings() + settings.enable_network_probe_mock = False + req = _make_request("quota", max_tokens=1) + with patch( + "free_claude_code.api.optimization_handlers.is_quota_check_request", + return_value=True, + ): + assert try_quota_mock(req, settings) is None + + def test_enabled_and_match_returns_response(self): + settings = Settings() + settings.enable_network_probe_mock = True + req = _make_request("quota", max_tokens=1) + with patch( + "free_claude_code.api.optimization_handlers.is_quota_check_request", + return_value=True, + ): + result = try_quota_mock(req, settings) + assert result is not None + block = result.content[0] + assert isinstance(block, ContentBlockText) + assert "Quota check passed" in block.text + + +class TestTryTitleSkip: + def test_disabled_returns_none(self): + settings = Settings() + settings.enable_title_generation_skip = False + req = _make_request("write a 5-10 word title") + with patch( + "free_claude_code.api.optimization_handlers.is_title_generation_request", + return_value=True, + ): + assert try_title_skip(req, settings) is None + + def test_enabled_and_match_returns_response(self): + settings = Settings() + settings.enable_title_generation_skip = True + req = _make_request("x") + with patch( + "free_claude_code.api.optimization_handlers.is_title_generation_request", + return_value=True, + ): + result = try_title_skip(req, settings) + assert result is not None + block = result.content[0] + assert isinstance(block, ContentBlockText) + assert block.text == "Conversation" + + +class TestTrySuggestionSkip: + def test_disabled_returns_none(self): + settings = Settings() + settings.enable_suggestion_mode_skip = False + req = _make_request("[SUGGESTION MODE: x]") + with patch( + "free_claude_code.api.optimization_handlers.is_suggestion_mode_request", + return_value=True, + ): + assert try_suggestion_skip(req, settings) is None + + def test_enabled_and_match_returns_response(self): + settings = Settings() + settings.enable_suggestion_mode_skip = True + req = _make_request("x") + with patch( + "free_claude_code.api.optimization_handlers.is_suggestion_mode_request", + return_value=True, + ): + result = try_suggestion_skip(req, settings) + assert result is not None + block = result.content[0] + assert isinstance(block, ContentBlockText) + assert block.text == "" + + +class TestTryFilepathMock: + def test_disabled_returns_none(self): + settings = Settings() + settings.enable_filepath_extraction_mock = False + req = _make_request("Command:\nls\nOutput:\nfilepaths") + with patch( + "free_claude_code.api.optimization_handlers.is_filepath_extraction_request", + return_value=(True, "ls", "out"), + ): + assert try_filepath_mock(req, settings) is None + + def test_enabled_and_match_returns_response(self): + settings = Settings() + settings.enable_filepath_extraction_mock = True + req = _make_request("x") + with ( + patch( + "free_claude_code.api.optimization_handlers.is_filepath_extraction_request", + return_value=(True, "ls", "a.txt b.txt"), + ), + patch( + "free_claude_code.api.optimization_handlers.extract_filepaths_from_command", + return_value="a.txt\nb.txt", + ), + ): + result = try_filepath_mock(req, settings) + assert result is not None + block = result.content[0] + assert isinstance(block, ContentBlockText) + assert block.text == "a.txt\nb.txt" + + def test_extract_filepaths_empty_list_still_returns_response(self): + settings = Settings() + settings.enable_filepath_extraction_mock = True + req = _make_request("x") + with ( + patch( + "free_claude_code.api.optimization_handlers.is_filepath_extraction_request", + return_value=(True, "ls", "out"), + ), + patch( + "free_claude_code.api.optimization_handlers.extract_filepaths_from_command", + return_value="", + ), + ): + result = try_filepath_mock(req, settings) + assert result is not None + block = result.content[0] + assert isinstance(block, ContentBlockText) + assert block.text == "" + + +class TestTryOptimizations: + def test_first_match_wins(self): + """Quota mock is first in OPTIMIZATION_HANDLERS; it should win over prefix.""" + settings = Settings() + settings.enable_network_probe_mock = True + settings.fast_prefix_detection = True + req = _make_request("quota", max_tokens=1) + with patch( + "free_claude_code.api.optimization_handlers.is_quota_check_request", + return_value=True, + ): + result = try_optimizations(req, settings) + assert result is not None + block = result.content[0] + assert isinstance(block, ContentBlockText) + assert "Quota check passed" in block.text + + def test_no_match_returns_none(self): + settings = Settings() + settings.fast_prefix_detection = False + settings.enable_network_probe_mock = False + settings.enable_title_generation_skip = False + settings.enable_suggestion_mode_skip = False + settings.enable_filepath_extraction_mock = False + req = _make_request("random user message") + assert try_optimizations(req, settings) is None diff --git a/tests/api/test_ordinary_error_phases.py b/tests/api/test_ordinary_error_phases.py new file mode 100644 index 0000000..71d435e --- /dev/null +++ b/tests/api/test_ordinary_error_phases.py @@ -0,0 +1,255 @@ +"""Ordinary ingress, routing, readiness, and preflight error contracts.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +from fastapi.testclient import TestClient + +from free_claude_code.application.errors import ( + ApplicationError, + ApplicationUnavailableError, + InvalidRequestError, + UnknownProviderError, +) +from free_claude_code.config.settings import Settings +from tests.api.support import create_test_app + +_PRODUCT_REQUESTS = ( + ( + "messages", + "/v1/messages", + { + "model": "open_router/test-model", + "messages": [{"role": "user", "content": "hello"}], + "stream": True, + }, + ), + ( + "responses", + "/v1/responses", + { + "model": "open_router/test-model", + "input": "hello", + }, + ), +) + + +def _settings(**updates: object) -> Settings: + return Settings().model_copy( + update={ + "anthropic_auth_token": "", + "log_api_error_tracebacks": False, + **updates, + } + ) + + +def _assert_ordinary_protocol_error( + response: httpx.Response, + *, + wire_api: str, + status_code: int, + error_type: str, + message: str, +) -> None: + assert response.status_code == status_code + assert response.headers["content-type"].startswith("application/json") + assert "x-should-retry" not in response.headers + + request_id = response.headers["request-id"] + assert request_id.startswith("req_") + if wire_api == "responses": + assert response.headers["x-request-id"] == request_id + assert response.json() == { + "error": { + "message": message, + "type": error_type, + "param": None, + "code": None, + } + } + return + + assert "x-request-id" not in response.headers + assert response.json() == { + "type": "error", + "error": {"type": error_type, "message": message}, + "request_id": request_id, + } + + +@pytest.mark.parametrize( + "error_type", + [InvalidRequestError, UnknownProviderError, ApplicationUnavailableError], +) +def test_application_errors_share_one_protocol_neutral_base( + error_type: type[ApplicationError], +) -> None: + assert isinstance(error_type("failure"), ApplicationError) + + +@pytest.mark.parametrize( + ("wire_api", "path", "payload"), + _PRODUCT_REQUESTS, + ids=("messages", "responses"), +) +def test_missing_provider_credential_is_protocol_specific_503_without_terminal_header( + wire_api: str, + path: str, + payload: dict[str, object], +) -> None: + message = ( + "OPENROUTER_API_KEY is not set. Add it to your .env file. " + "Get a key at https://openrouter.ai/keys" + ) + app = create_test_app( + _settings( + model="open_router/test-model", + open_router_api_key="", + ) + ) + + with TestClient(app) as client: + response = client.post(path, json=payload) + + _assert_ordinary_protocol_error( + response, + wire_api=wire_api, + status_code=503, + error_type="api_error", + message=message, + ) + + +@pytest.mark.parametrize( + ("wire_api", "path", "payload"), + _PRODUCT_REQUESTS, + ids=("messages", "responses"), +) +def test_runtime_acquisition_failure_is_protocol_specific_503_without_terminal_header( + wire_api: str, + path: str, + payload: dict[str, object], +) -> None: + message = "Provider runtime is shutting down." + app = create_test_app(_settings()) + acquire = AsyncMock(side_effect=ApplicationUnavailableError(message)) + + with ( + patch.object(app.state.services.requests, "acquire", new=acquire), + TestClient(app) as client, + ): + response = client.post(path, json=payload) + + acquire.assert_awaited_once() + _assert_ordinary_protocol_error( + response, + wire_api=wire_api, + status_code=503, + error_type="api_error", + message=message, + ) + + +@pytest.mark.parametrize( + ("wire_api", "path", "payload"), + _PRODUCT_REQUESTS, + ids=("messages", "responses"), +) +def test_unknown_provider_is_protocol_specific_400_without_terminal_header( + wire_api: str, + path: str, + payload: dict[str, object], +) -> None: + message = "Unknown provider_type: 'unknown'." + app = create_test_app(_settings()) + + with ( + patch( + "free_claude_code.api.routes.resolve_provider", + side_effect=UnknownProviderError(message), + ), + TestClient(app) as client, + ): + response = client.post(path, json=payload) + + _assert_ordinary_protocol_error( + response, + wire_api=wire_api, + status_code=400, + error_type="invalid_request_error", + message=message, + ) + + +@pytest.mark.parametrize( + ("wire_api", "path", "payload"), + _PRODUCT_REQUESTS, + ids=("messages", "responses"), +) +def test_preflight_rejection_is_protocol_specific_400_without_terminal_header( + wire_api: str, + path: str, + payload: dict[str, object], +) -> None: + message = "bad tool shape" + provider = MagicMock() + provider.preflight_stream.side_effect = InvalidRequestError(message) + app = create_test_app(_settings()) + + with ( + patch( + "free_claude_code.api.routes.resolve_provider", + return_value=provider, + ), + TestClient(app) as client, + ): + response = client.post(path, json=payload) + + provider.stream_response.assert_not_called() + _assert_ordinary_protocol_error( + response, + wire_api=wire_api, + status_code=400, + error_type="invalid_request_error", + message=message, + ) + + +@pytest.mark.parametrize( + ("wire_api", "path", "payload"), + _PRODUCT_REQUESTS, + ids=("messages", "responses"), +) +@pytest.mark.parametrize( + ("headers", "detail"), + [ + ({}, "Missing API key"), + ({"x-api-key": "wrong"}, "Invalid API key"), + ], + ids=("missing", "invalid"), +) +def test_proxy_auth_preserves_fastapi_detail_contract( + wire_api: str, + path: str, + payload: dict[str, object], + headers: dict[str, str], + detail: str, +) -> None: + app = create_test_app(_settings(anthropic_auth_token="secret")) + + with TestClient(app) as client: + response = client.post(path, json=payload, headers=headers) + + assert response.status_code == 401 + assert response.json() == {"detail": detail} + assert response.headers["content-type"].startswith("application/json") + assert "x-should-retry" not in response.headers + request_id = response.headers["request-id"] + assert request_id.startswith("req_") + if wire_api == "responses": + assert response.headers["x-request-id"] == request_id + else: + assert "x-request-id" not in response.headers diff --git a/tests/api/test_request_ids.py b/tests/api/test_request_ids.py new file mode 100644 index 0000000..29219b7 --- /dev/null +++ b/tests/api/test_request_ids.py @@ -0,0 +1,140 @@ +"""Tests for the pure-ASGI ingress correlation owner.""" + +import asyncio +from collections.abc import Iterator +from contextlib import contextmanager +from typing import cast +from unittest.mock import patch + +import pytest +from fastapi import Request +from starlette.datastructures import Headers +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp, Message, Scope + +from free_claude_code.api.request_ids import ( + RequestCorrelationMiddleware, + get_request_id, +) +from tests.api.support import create_test_app + + +def _http_scope(path: str) -> Scope: + return cast( + Scope, + { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.4"}, + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "headers": [(b"anthropic-session-id", b"session_test")], + "client": None, + "server": None, + }, + ) + + +def test_application_uses_the_pure_asgi_correlation_owner() -> None: + app = create_test_app() + middleware_classes = [middleware.cls for middleware in app.user_middleware] + + assert sum(cls is RequestCorrelationMiddleware for cls in middleware_classes) == 1 + assert all(cls is not BaseHTTPMiddleware for cls in middleware_classes) + + +@pytest.mark.asyncio +async def test_correlation_context_and_headers_span_the_complete_stream() -> None: + response_started = asyncio.Event() + allow_body = asyncio.Event() + sent: list[Message] = [] + context_entries: list[dict[str, object]] = [] + context_exits: list[dict[str, object]] = [] + app_request_id: str | None = None + + async def app(scope: Scope, _receive, send) -> None: + nonlocal app_request_id + app_request_id = get_request_id(Request(scope)) + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [], + } + ) + response_started.set() + await allow_body.wait() + await send( + { + "type": "http.response.body", + "body": b"done", + "more_body": False, + } + ) + + @contextmanager + def contextualize(**fields: object) -> Iterator[None]: + context_entries.append(fields) + try: + yield + finally: + context_exits.append(fields) + + async def receive() -> Message: + raise AssertionError("Test application does not receive messages") + + async def send(message: Message) -> None: + sent.append(message) + + middleware = RequestCorrelationMiddleware(cast(ASGIApp, app)) + with patch( + "free_claude_code.api.request_ids.logger.contextualize", + side_effect=contextualize, + ): + request = asyncio.create_task( + middleware(_http_scope("/v1/responses"), receive, send) + ) + await response_started.wait() + + assert context_exits == [] + assert app_request_id is not None + headers = Headers(raw=sent[0]["headers"]) + assert headers["request-id"] == app_request_id + assert headers["x-request-id"] == app_request_id + assert context_entries == [ + { + "http_method": "POST", + "http_path": "/v1/responses", + "claude_session_id": "session_test", + "request_id": app_request_id, + } + ] + + allow_body.set() + await request + + assert context_exits == context_entries + + +@pytest.mark.asyncio +async def test_correlation_middleware_passes_non_http_scopes_unchanged() -> None: + observed_scope: Scope | None = None + + async def app(scope: Scope, _receive, _send) -> None: + nonlocal observed_scope + observed_scope = scope + + async def receive() -> Message: + return {"type": "lifespan.startup"} + + async def send(_message: Message) -> None: + return None + + scope = cast(Scope, {"type": "lifespan", "asgi": {"version": "3.0"}}) + await RequestCorrelationMiddleware(cast(ASGIApp, app))(scope, receive, send) + + assert observed_scope is scope + assert "state" not in scope diff --git a/tests/api/test_request_utils.py b/tests/api/test_request_utils.py new file mode 100644 index 0000000..6a1d310 --- /dev/null +++ b/tests/api/test_request_utils.py @@ -0,0 +1,641 @@ +"""Tests for API request detection and token counting helpers.""" + +from unittest.mock import MagicMock + +import pytest + +from free_claude_code.api.command_utils import extract_command_prefix +from free_claude_code.api.detection import ( + is_prefix_detection_request, + is_quota_check_request, + is_title_generation_request, +) +from free_claude_code.core.anthropic import get_token_count +from free_claude_code.core.anthropic.models import ( + Message, + MessagesRequest, + SystemContent, +) + + +class TestQuotaCheckRequest: + """Tests for is_quota_check_request function.""" + + def test_quota_check_simple_string(self): + """Test quota check with simple string content.""" + msg = MagicMock(spec=Message) + msg.role = "user" + msg.content = "Check my quota" + + req = MagicMock(spec=MessagesRequest) + req.max_tokens = 1 + req.messages = [msg] + + assert is_quota_check_request(req) is True + + def test_quota_check_case_insensitive(self): + """Test quota check is case insensitive.""" + msg = MagicMock(spec=Message) + msg.role = "user" + msg.content = "Check my QUOTA" + + req = MagicMock(spec=MessagesRequest) + req.max_tokens = 1 + req.messages = [msg] + + assert is_quota_check_request(req) is True + + def test_quota_check_list_content(self): + """Test quota check with list content blocks.""" + block = MagicMock() + block.text = "Check my quota" + + msg = MagicMock(spec=Message) + msg.role = "user" + msg.content = [block] + + req = MagicMock(spec=MessagesRequest) + req.max_tokens = 1 + req.messages = [msg] + + assert is_quota_check_request(req) is True + + def test_not_quota_check_wrong_max_tokens(self): + """Test not quota check when max_tokens != 1.""" + msg = MagicMock(spec=Message) + msg.role = "user" + msg.content = "Check my quota" + + req = MagicMock(spec=MessagesRequest) + req.max_tokens = 100 + req.messages = [msg] + + assert is_quota_check_request(req) is False + + def test_not_quota_check_multiple_messages(self): + """Test not quota check when multiple messages.""" + msg1 = MagicMock(spec=Message) + msg1.role = "user" + msg1.content = "Check my quota" + + msg2 = MagicMock(spec=Message) + msg2.role = "assistant" + msg2.content = "Hello" + + req = MagicMock(spec=MessagesRequest) + req.max_tokens = 1 + req.messages = [msg1, msg2] + + assert is_quota_check_request(req) is False + + def test_not_quota_check_wrong_role(self): + """Test not quota check when role is not user.""" + msg = MagicMock(spec=Message) + msg.role = "assistant" + msg.content = "Check my quota" + + req = MagicMock(spec=MessagesRequest) + req.max_tokens = 1 + req.messages = [msg] + + assert is_quota_check_request(req) is False + + def test_not_quota_check_no_quota_keyword(self): + """Test not quota check when content doesn't contain quota.""" + msg = MagicMock(spec=Message) + msg.role = "user" + msg.content = "Hello world" + + req = MagicMock(spec=MessagesRequest) + req.max_tokens = 1 + req.messages = [msg] + + assert is_quota_check_request(req) is False + + +class TestTitleGenerationRequest: + """Tests for is_title_generation_request function.""" + + def _title_gen_system(self) -> list[MagicMock]: + block = MagicMock() + block.text = ( + "Generate a concise, sentence-case title (3-7 words) that captures the " + "main topic or goal of this coding session. Return JSON with a single " + '"title" field.' + ) + return [block] + + def test_title_generation_detected_via_system(self): + """Title gen detected by session title system prompt (sentence-case / JSON).""" + req = MagicMock(spec=MessagesRequest) + req.system = self._title_gen_system() + req.tools = None + + assert is_title_generation_request(req) is True + + def test_title_generation_not_detected_with_tools(self): + """Not detected when tools are present (main conversation, not title gen).""" + req = MagicMock(spec=MessagesRequest) + req.system = self._title_gen_system() + req.tools = [MagicMock()] + + assert is_title_generation_request(req) is False + + def test_title_generation_not_detected_no_system(self): + """Not detected when system is absent.""" + req = MagicMock(spec=MessagesRequest) + req.system = None + req.tools = None + + assert is_title_generation_request(req) is False + + def test_title_generation_not_detected_unrelated_system(self): + """Not detected when system prompt has no topic/title keywords.""" + block = MagicMock() + block.text = "You are a helpful assistant." + req = MagicMock(spec=MessagesRequest) + req.system = [block] + req.tools = None + + assert is_title_generation_request(req) is False + + def test_title_generation_return_json_coding_session_branch(self): + """JSON title field + session wording matches without sentence-case phrase.""" + block = MagicMock() + block.text = 'Return JSON with a single "title" field for this coding session.' + req = MagicMock(spec=MessagesRequest) + req.system = [block] + req.tools = None + + assert is_title_generation_request(req) is True + + +class TestExtractCommandPrefix: + """Tests for extract_command_prefix function.""" + + def test_simple_command(self): + """Test extraction of simple command.""" + assert extract_command_prefix("git status") == "git status" + assert extract_command_prefix("ls -la") == "ls" + + def test_two_word_commands(self): + """Test extraction of two-word commands.""" + assert extract_command_prefix("git commit -m 'message'") == "git commit" + assert extract_command_prefix("npm install package") == "npm install" + assert extract_command_prefix("docker run image") == "docker run" + assert extract_command_prefix("kubectl get pods") == "kubectl get" + + def test_two_word_command_with_options(self): + """Test two-word command with options only returns first word.""" + assert extract_command_prefix("git -v") == "git" + assert extract_command_prefix("npm --version") == "npm" + + def test_with_env_vars(self): + """Test command with environment variables.""" + assert extract_command_prefix("DEBUG=1 python script.py") == "DEBUG=1 python" + assert ( + extract_command_prefix("API_KEY=secret node app.js") + == "API_KEY=secret node" + ) + + def test_single_word_commands(self): + """Test single word commands.""" + assert extract_command_prefix("ls") == "ls" + assert extract_command_prefix("python") == "python" + assert extract_command_prefix("make") == "make" + + def test_command_injection_detected(self): + """Test detection of command injection attempts.""" + assert extract_command_prefix("`whoami`") == "command_injection_detected" + assert extract_command_prefix("$(whoami)") == "command_injection_detected" + assert ( + extract_command_prefix("echo $(cat /etc/passwd)") + == "command_injection_detected" + ) + + def test_empty_command(self): + """Test handling of empty commands.""" + assert extract_command_prefix("") == "none" + assert extract_command_prefix(" ") == "none" + + def test_complex_git_command(self): + """Test complex git command extraction.""" + assert extract_command_prefix("git log --oneline --graph") == "git log" + assert ( + extract_command_prefix("git checkout -b feature-branch") == "git checkout" + ) + + def test_cargo_command(self): + """Test cargo command extraction.""" + assert extract_command_prefix("cargo build") == "cargo build" + assert extract_command_prefix("cargo test") == "cargo test" + assert extract_command_prefix("cargo --version") == "cargo" + + +class TestPrefixDetectionRequest: + """Tests for is_prefix_detection_request function.""" + + def test_prefix_detection_with_policy_spec(self): + """Test prefix detection with policy spec and command.""" + msg = MagicMock(spec=Message) + msg.role = "user" + msg.content = "policy Command: git status" + + req = MagicMock(spec=MessagesRequest) + req.messages = [msg] + + is_prefix, command = is_prefix_detection_request(req) + assert is_prefix is True + assert command == "git status" + + def test_prefix_detection_case_sensitive(self): + """Test prefix detection is case sensitive for Command:.""" + msg = MagicMock(spec=Message) + msg.role = "user" + msg.content = "policy command: git status" + + req = MagicMock(spec=MessagesRequest) + req.messages = [msg] + + is_prefix, command = is_prefix_detection_request(req) + assert is_prefix is False + assert command == "" + + def test_not_prefix_detection_no_policy_spec(self): + """Test not prefix detection without policy_spec.""" + msg = MagicMock(spec=Message) + msg.role = "user" + msg.content = "Command: git status" + + req = MagicMock(spec=MessagesRequest) + req.messages = [msg] + + is_prefix, command = is_prefix_detection_request(req) + assert is_prefix is False + assert command == "" + + def test_not_prefix_detection_multiple_messages(self): + """Test not prefix detection with multiple messages.""" + msg1 = MagicMock(spec=Message) + msg1.role = "user" + msg1.content = "policy Command: git status" + + msg2 = MagicMock(spec=Message) + msg2.role = "assistant" + msg2.content = "OK" + + req = MagicMock(spec=MessagesRequest) + req.messages = [msg1, msg2] + + is_prefix, command = is_prefix_detection_request(req) + assert is_prefix is False + assert command == "" + + def test_not_prefix_detection_wrong_role(self): + """Test not prefix detection when message is not from user.""" + msg = MagicMock(spec=Message) + msg.role = "assistant" + msg.content = "policy Command: git status" + + req = MagicMock(spec=MessagesRequest) + req.messages = [msg] + + is_prefix, command = is_prefix_detection_request(req) + assert is_prefix is False + assert command == "" + + def test_prefix_detection_list_content(self): + """Test prefix detection with list content blocks.""" + block = MagicMock() + block.text = "policy Command: ls -la" + + msg = MagicMock(spec=Message) + msg.role = "user" + msg.content = [block] + + req = MagicMock(spec=MessagesRequest) + req.messages = [msg] + + is_prefix, command = is_prefix_detection_request(req) + assert is_prefix is True + assert command == "ls -la" + + +class TestGetTokenCount: + """Tests for get_token_count function.""" + + def test_empty_messages(self): + """Test token count with empty messages.""" + count = get_token_count([]) + assert count >= 1 # Returns max(1, tokens) + + def test_simple_message(self): + """Test token count with simple text message.""" + msg = MagicMock() + msg.content = "Hello world" + + count = get_token_count([msg]) + assert count > 0 + # "Hello world" is ~2-3 tokens plus overhead + assert count >= 3 + + def test_special_token_text_is_counted_as_plain_text(self): + """Tiktoken special-token strings should not break token estimates.""" + msg = MagicMock() + msg.content = "<|endoftext|>" + + count = get_token_count([msg], system="<|endoftext|>") + assert count > 0 + + def test_message_with_system_prompt(self): + """Test token count includes system prompt.""" + msg = MagicMock() + msg.content = "Hello" + + count = get_token_count([msg], system="You are a helpful assistant") + assert count > 0 + + def test_message_with_list_content(self): + """Test token count with list content blocks.""" + text_block = MagicMock() + text_block.type = "text" + text_block.text = "Hello world" + + msg = MagicMock() + msg.content = [text_block] + + count = get_token_count([msg]) + assert count > 0 + + def test_message_with_thinking_block(self): + """Test token count includes thinking blocks.""" + thinking_block = MagicMock() + thinking_block.type = "thinking" + thinking_block.thinking = "Let me think about this..." + + msg = MagicMock() + msg.content = [thinking_block] + + count = get_token_count([msg]) + assert count > 0 + + def test_message_with_tool_use(self): + """Test token count includes tool use blocks.""" + tool_block = MagicMock() + tool_block.type = "tool_use" + tool_block.name = "search" + tool_block.input = {"query": "test"} + + msg = MagicMock() + msg.content = [tool_block] + + count = get_token_count([msg]) + assert count > 0 + + def test_message_with_tool_result(self): + """Test token count includes tool result blocks.""" + result_block = MagicMock() + result_block.type = "tool_result" + result_block.content = "Search results here" + + msg = MagicMock() + msg.content = [result_block] + + count = get_token_count([msg]) + assert count > 0 + + def test_message_with_tools(self): + """Test token count includes tool definitions.""" + msg = MagicMock() + msg.content = "Use the search tool" + + tool = MagicMock() + tool.name = "search" + tool.description = "Search for information" + tool.input_schema = {"type": "object", "properties": {}} + + count = get_token_count([msg], tools=[tool]) + assert count > 0 + + def test_system_as_list(self): + """Test token count with system as list of blocks.""" + msg = MagicMock() + msg.content = "Hello" + + block = MagicMock() + block.text = "System prompt" + + count = get_token_count([msg], system=[block]) + assert count > 0 + + def test_tool_result_with_dict_content(self): + """Test token count with tool result containing dict content.""" + result_block = MagicMock() + result_block.type = "tool_result" + result_block.content = {"result": "data"} + + msg = MagicMock() + msg.content = [result_block] + + count = get_token_count([msg]) + assert count > 0 + + def test_multiple_messages_overhead(self): + """Test that multiple messages include overhead.""" + msg1 = MagicMock() + msg1.content = "Hi" + + msg2 = MagicMock() + msg2.content = "Hello" + + count_single = get_token_count([msg1]) + count_double = get_token_count([msg1, msg2]) + + # Double message should have more tokens (including overhead) + assert count_double > count_single + + def test_per_message_overhead_four_tokens(self): + """Per-message overhead is 4 tokens (was 3).""" + msg = MagicMock() + msg.content = "x" # Minimal content + count = get_token_count([msg]) + # 1 msg * 4 overhead + content tokens + assert count >= 5 + + def test_system_overhead_added(self): + """System prompt adds ~4 tokens overhead.""" + msg = MagicMock() + msg.content = "Hi" + count_no_sys = get_token_count([msg]) + count_with_sys = get_token_count([msg], system="You are helpful") + assert count_with_sys >= count_no_sys + 4 + + def test_system_as_typed_content_blocks(self): + """Typed system content blocks are counted.""" + msg = MagicMock() + msg.content = "Hi" + count_no_sys = get_token_count([msg]) + system_blocks = [ + SystemContent(type="text", text="System prompt from typed block") + ] + count_with_system = get_token_count([msg], system=system_blocks) + assert count_with_system > count_no_sys + + def test_tool_use_includes_id(self): + """Tool use blocks count id field.""" + tool_block = MagicMock() + tool_block.type = "tool_use" + tool_block.name = "search" + tool_block.input = {"q": "test"} + tool_block.id = "call_abc123" + msg = MagicMock() + msg.content = [tool_block] + count = get_token_count([msg]) + assert count > 0 + + def test_tool_result_includes_tool_use_id(self): + """Tool result blocks count tool_use_id field.""" + result_block = MagicMock() + result_block.type = "tool_result" + result_block.content = "ok" + result_block.tool_use_id = "call_xyz" + msg = MagicMock() + msg.content = [result_block] + count = get_token_count([msg]) + assert count > 0 + + def test_unrecognized_block_type_fallback(self): + """Unrecognized block types are tokenized via json.dumps fallback.""" + unknown_block = {"type": "custom", "spec": "data"} + msg = MagicMock() + msg.content = [unknown_block] + count = get_token_count([msg]) + assert count > 0 + + def test_message_with_image_block(self): + """Test token count includes image blocks.""" + image_block = MagicMock() + image_block.type = "image" + image_block.source = { + "type": "base64", + "media_type": "image/png", + "data": "x" * 3000, + } + msg = MagicMock() + msg.content = [image_block] + count = get_token_count([msg]) + assert count >= 85 + + def test_image_block_with_dict_source(self): + """Image block with dict-style source is counted.""" + image_block = {"type": "image", "source": {"data": "a" * 10000}} + msg = MagicMock() + msg.content = [image_block] + count = get_token_count([msg]) + assert count >= 85 + + def test_known_payload_estimate_range(self): + """Known payload produces estimate within expected range (validation harness).""" + import tiktoken + + enc = tiktoken.get_encoding("cl100k_base") + system_text = "You are a helpful assistant." + user_text = "Hello, how are you?" + sys_tokens = len(enc.encode(system_text)) + user_tokens = len(enc.encode(user_text)) + # Min: content tokens + system overhead (4) + per-msg overhead (4) + expected_min = sys_tokens + user_tokens + 4 + 4 + msg = MagicMock() + msg.content = user_text + count = get_token_count([msg], system=system_text) + assert count >= expected_min, f"count={count} < expected_min={expected_min}" + + +# --- Parametrized Edge Case Tests --- + + +@pytest.mark.parametrize( + "command,expected", + [ + ("git status", "git status"), + ("ls -la", "ls"), + ("git commit -m 'msg'", "git commit"), + ("npm install pkg", "npm install"), + ("ls", "ls"), + ("python", "python"), + ("", "none"), + (" ", "none"), + ("`whoami`", "command_injection_detected"), + ("$(whoami)", "command_injection_detected"), + ("echo $(cat /etc/passwd)", "command_injection_detected"), + ("git -v", "git"), + ("DEBUG=1 python script.py", "DEBUG=1 python"), + ("cargo build", "cargo build"), + ("cargo --version", "cargo"), + ], + ids=[ + "git_status", + "ls_with_flag", + "git_commit", + "npm_install", + "bare_ls", + "bare_python", + "empty", + "whitespace", + "injection_backtick", + "injection_dollar", + "injection_echo", + "git_flag", + "env_var", + "cargo_build", + "cargo_flag", + ], +) +def test_extract_command_prefix_parametrized(command, expected): + """Parametrized command prefix extraction.""" + assert extract_command_prefix(command) == expected + + +def test_extract_command_prefix_unterminated_quote(): + """Unterminated quote falls back to simple split (shlex.split ValueError).""" + result = extract_command_prefix("git commit -m 'unterminated") + # Should fall back to command.split()[0] = "git" + assert result == "git" + + +def test_extract_command_prefix_pipe(): + """Piped commands - shlex handles pipe character.""" + result = extract_command_prefix("cat file.txt | grep pattern") + assert result in ("cat", "cat file.txt") + + +@pytest.mark.parametrize( + "content,max_tokens,role,expected", + [ + ("Check my quota", 1, "user", True), + ("Check my QUOTA", 1, "user", True), + ("Hello world", 1, "user", False), + ("Check my quota", 100, "user", False), + ("Check my quota", 1, "assistant", False), + ], + ids=["basic", "case_insensitive", "no_keyword", "wrong_max_tokens", "wrong_role"], +) +def test_quota_check_parametrized(content, max_tokens, role, expected): + """Parametrized quota check request detection.""" + msg = MagicMock(spec=Message) + msg.role = role + msg.content = content + + req = MagicMock(spec=MessagesRequest) + req.max_tokens = max_tokens + req.messages = [msg] + + assert is_quota_check_request(req) is expected + + +def test_quota_check_empty_messages(): + """Quota check with empty message list should not crash.""" + req = MagicMock(spec=MessagesRequest) + req.max_tokens = 1 + req.messages = [] + assert is_quota_check_request(req) is False diff --git a/tests/api/test_request_utils_filepaths_and_suggestions.py b/tests/api/test_request_utils_filepaths_and_suggestions.py new file mode 100644 index 0000000..67a88ee --- /dev/null +++ b/tests/api/test_request_utils_filepaths_and_suggestions.py @@ -0,0 +1,130 @@ +from unittest.mock import MagicMock + +import pytest + +from free_claude_code.api.command_utils import extract_filepaths_from_command +from free_claude_code.api.detection import ( + is_filepath_extraction_request, + is_suggestion_mode_request, +) +from free_claude_code.core.anthropic.models import Message, MessagesRequest + + +def _mk_req(messages, tools=None, system=None): + req = MagicMock(spec=MessagesRequest) + req.messages = messages + req.tools = tools + req.system = system + return req + + +def _mk_msg(role: str, content): + msg = MagicMock(spec=Message) + msg.role = role + msg.content = content + return msg + + +class TestSuggestionMode: + def test_detects_suggestion_mode_in_any_user_message(self): + req = _mk_req( + [ + _mk_msg("assistant", "ignore"), + _mk_msg("user", "Hello\n[SUGGESTION MODE: on]\nworld"), + ] + ) + assert is_suggestion_mode_request(req) is True + + def test_suggestion_mode_ignores_non_user_messages(self): + req = _mk_req([_mk_msg("assistant", "[SUGGESTION MODE: on]")]) + assert is_suggestion_mode_request(req) is False + + +class TestFilepathExtractionDetection: + def test_rejects_when_tools_present(self): + msg = _mk_msg( + "user", + "Command: cat foo.txt\nOutput: hi\n\nPlease extract .", + ) + req = _mk_req([msg], tools=[{"name": "search"}]) + ok, cmd, out = is_filepath_extraction_request(req) + assert (ok, cmd, out) == (False, "", "") + + def test_rejects_when_missing_output_marker(self): + msg = _mk_msg( + "user", + "Command: cat foo.txt\n(no output marker)\n", + ) + req = _mk_req([msg], tools=None) + ok, cmd, out = is_filepath_extraction_request(req) + assert (ok, cmd, out) == (False, "", "") + + def test_rejects_when_not_asking_for_filepaths(self): + msg = _mk_msg("user", "Command: cat foo.txt\nOutput: hi") + req = _mk_req([msg], tools=None) + ok, cmd, out = is_filepath_extraction_request(req) + assert (ok, cmd, out) == (False, "", "") + + def test_detects_filepath_extraction_via_system_block(self): + """Command: + Output: in user, no filepaths in user; system has extract instructions.""" + msg = _mk_msg("user", "Command: ls\nOutput: avazu-ctr\nfree-claude-code") + req = _mk_req( + [msg], + tools=None, + system="Extract any file paths that this command reads or modifies.", + ) + ok, cmd, out = is_filepath_extraction_request(req) + assert ok is True + assert cmd == "ls" + assert "avazu-ctr" in out + assert "free-claude-code" in out + + def test_extracts_command_and_output_and_cleans_output(self): + msg = _mk_msg( + "user", + "Command: cat foo.txt\n" + "Output: line1\nline2\n\n" + "Please extract .\n" + "ignore me", + ) + req = _mk_req([msg], tools=None) + ok, cmd, out = is_filepath_extraction_request(req) + assert ok is True + assert cmd == "cat foo.txt" + assert out == "line1\nline2" + + +class TestExtractFilepathsFromCommand: + @pytest.mark.parametrize( + "command,expected_paths", + [ + ("ls -la", []), + ("dir .", []), + ("cat foo.txt", ["foo.txt"]), + ("cat -n foo.txt bar.md", ["foo.txt", "bar.md"]), + ("type C:\\tmp\\a.txt", ["C:\\tmp\\a.txt"]), + ("grep pattern file1.txt file2.txt", ["file1.txt", "file2.txt"]), + ("grep -n pattern file.txt", ["file.txt"]), + ("grep -e pattern file.txt", ["file.txt"]), + ("unknowncmd arg1 arg2", []), + ("", []), + ], + ids=[ + "listing_ls", + "listing_dir", + "read_cat", + "read_cat_flags", + "read_type_windows_path", + "grep_simple", + "grep_with_flag", + "grep_with_e", + "unknown", + "empty", + ], + ) + def test_extracts_expected_paths(self, command, expected_paths): + result = extract_filepaths_from_command(command, output="(ignored)") + for p in expected_paths: + assert p in result + if not expected_paths: + assert result.strip() == "\n" diff --git a/tests/api/test_response_streams.py b/tests/api/test_response_streams.py new file mode 100644 index 0000000..ce71e68 --- /dev/null +++ b/tests/api/test_response_streams.py @@ -0,0 +1,647 @@ +"""Tests for public SSE response start gating.""" + +import asyncio +import json +from collections.abc import AsyncGenerator +from typing import Any, cast +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.responses import JSONResponse, StreamingResponse +from starlette.types import Message, Scope + +from free_claude_code.api.request_ids import RequestCorrelationMiddleware +from free_claude_code.api.response_streams import ( + ManagedStreamingResponse, + anthropic_sse_streaming_response, + bind_response_lifetime, + terminal_execution_error_response, +) +from free_claude_code.core.anthropic import ( + anthropic_error_payload, + anthropic_failure_payload, +) +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.core.failures import ExecutionFailure, FailureKind + + +async def _body_chunks(chunks: list[str]) -> AsyncGenerator[str]: + for chunk in chunks: + yield chunk + + +async def _body_raises(exc: BaseException) -> AsyncGenerator[str]: + raise exc + yield "unreachable" + + +async def _body_then_raises( + chunks: list[str], exc: BaseException +) -> AsyncGenerator[str]: + for chunk in chunks: + yield chunk + raise exc + + +def _json_error(exc: BaseException) -> JSONResponse: + if isinstance(exc, ExecutionFailure): + return terminal_execution_error_response( + status_code=exc.status_code, + content=anthropic_failure_payload(exc), + ) + return JSONResponse( + status_code=500, + content={ + "type": "error", + "error": {"type": "api_error", "message": "failed"}, + }, + ) + + +async def _drain(response: StreamingResponse) -> str: + parts = [ + chunk.decode("utf-8") if isinstance(chunk, bytes) else str(chunk) + async for chunk in response.body_iterator + ] + return "".join(parts) + + +def _http_scope() -> Scope: + return cast( + Scope, + { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.4"}, + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": "/v1/messages", + "raw_path": b"/v1/messages", + "query_string": b"", + "headers": [], + "client": None, + "server": None, + }, + ) + + +async def _serve( + response: StreamingResponse, + *, + send: Any | None = None, +) -> list[Message]: + messages: list[Message] = [] + + async def receive() -> Message: + raise AssertionError("ASGI spec 2.4 responses must not read receive") + + async def collect(message: Message) -> None: + messages.append(message) + + await response(_http_scope(), receive, send or collect) + return messages + + +@pytest.mark.asyncio +async def test_anthropic_response_waits_for_first_chunk_before_returning() -> None: + ready = asyncio.Event() + + async def body() -> AsyncGenerator[str]: + await ready.wait() + yield 'event: message_start\ndata: {"type":"message_start"}\n\n' + + task = asyncio.create_task( + anthropic_sse_streaming_response( + body(), + pre_start_error_response=_json_error, + request_id="req_test", + ) + ) + + await asyncio.sleep(0) + assert not task.done() + + ready.set() + response = await asyncio.wait_for(task, timeout=1) + assert isinstance(response, StreamingResponse) + assert "message_start" in await _drain(response) + + +@pytest.mark.asyncio +async def test_anthropic_pre_start_provider_error_returns_non_200_json() -> None: + response = await anthropic_sse_streaming_response( + _body_raises( + ExecutionFailure( + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="provider says slow down", + retryable=True, + ) + ), + pre_start_error_response=_json_error, + request_id="req_test", + ) + + assert isinstance(response, JSONResponse) + assert response.status_code == 429 + assert response.headers["x-should-retry"] == "false" + body = json.loads(bytes(response.body)) + assert body["error"]["type"] == "rate_limit_error" + assert body["error"]["message"] == "provider says slow down" + + +@pytest.mark.asyncio +async def test_pre_start_failure_closes_body_before_response_release() -> None: + lifecycle: list[str] = [] + + class FailingBody: + def __aiter__(self): + return self + + async def __anext__(self) -> str: + raise RuntimeError("provider failed") + + async def aclose(self) -> None: + lifecycle.append("body_closed") + + async def release() -> None: + lifecycle.append("lease_released") + + response = await anthropic_sse_streaming_response( + FailingBody(), + pre_start_error_response=_json_error, + request_id="req_pre_start_order", + ) + await bind_response_lifetime(response, release) + + assert lifecycle == ["body_closed", "lease_released"] + + +@pytest.mark.asyncio +async def test_terminal_execution_error_response_disables_client_retry() -> None: + response = terminal_execution_error_response( + status_code=429, + content=anthropic_error_payload( + error_type="rate_limit_error", + message="provider says slow down", + ), + ) + + assert isinstance(response, JSONResponse) + assert response.status_code == 429 + assert response.headers["x-should-retry"] == "false" + body = json.loads(bytes(response.body)) + assert body["error"] == { + "type": "rate_limit_error", + "message": "provider says slow down", + } + + +@pytest.mark.asyncio +async def test_anthropic_post_start_exception_emits_terminal_error_frame() -> None: + response = await anthropic_sse_streaming_response( + _body_then_raises( + ['event: message_start\ndata: {"type":"message_start"}\n\n'], + RuntimeError("socket cut"), + ), + pre_start_error_response=_json_error, + request_id="req_test", + ) + + assert isinstance(response, StreamingResponse) + text = await _drain(response) + events = parse_sse_text(text) + assert [event.event for event in events] == ["message_start", "error"] + assert events[-1].data["error"]["message"] == "socket cut" + + +@pytest.mark.asyncio +async def test_non_streaming_response_releases_resource_before_return() -> None: + release = AsyncMock() + response = JSONResponse({"ok": True}) + + result = await bind_response_lifetime(response, release) + + assert result is response + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_unmanaged_stream_is_closed_and_released_before_rejection() -> None: + release = AsyncMock() + close = AsyncMock() + + class Body: + def __aiter__(self): + return self + + async def __anext__(self) -> str: + return "unreachable" + + async def aclose(self) -> None: + await close() + + response = StreamingResponse(Body()) + + with pytest.raises(TypeError, match="ManagedStreamingResponse"): + await bind_response_lifetime(response, release) + + close.assert_awaited_once() + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_streaming_response_releases_after_normal_completion() -> None: + release = AsyncMock() + response = ManagedStreamingResponse(_body_chunks(["one", "two"])) + + result = await bind_response_lifetime(response, release) + + assert result is response + release.assert_not_awaited() + messages = await _serve(response) + assert b"".join(message.get("body", b"") for message in messages) == b"onetwo" + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_streaming_response_releases_after_body_failure() -> None: + release = AsyncMock() + response = ManagedStreamingResponse( + _body_then_raises(["one"], RuntimeError("stream failed")) + ) + await bind_response_lifetime(response, release) + + with pytest.raises(RuntimeError, match="stream failed"): + await _serve(response) + + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_streaming_response_releases_when_consumer_closes_early() -> None: + release = AsyncMock() + source_closed = asyncio.Event() + + async def body() -> AsyncGenerator[str]: + try: + yield "one" + yield "two" + finally: + source_closed.set() + + response = await anthropic_sse_streaming_response( + body(), + pre_start_error_response=_json_error, + request_id="req_test", + ) + assert isinstance(response, ManagedStreamingResponse) + await bind_response_lifetime(response, release) + + await response.aclose() + + assert source_closed.is_set() + release.assert_awaited_once() + + await response.aclose() + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_streaming_response_releases_when_consumer_is_cancelled() -> None: + release = AsyncMock() + entered = asyncio.Event() + source_closed = asyncio.Event() + + async def body() -> AsyncGenerator[str]: + try: + yield "one" + entered.set() + await asyncio.Event().wait() + finally: + source_closed.set() + + response = ManagedStreamingResponse(body()) + await bind_response_lifetime(response, release) + drain_task = asyncio.create_task(_serve(response)) + await entered.wait() + + drain_task.cancel() + with pytest.raises(asyncio.CancelledError): + await drain_task + + assert source_closed.is_set() + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_response_start_send_failure_closes_prefetched_tail_and_releases() -> ( + None +): + release = AsyncMock() + source_closed = asyncio.Event() + + async def body() -> AsyncGenerator[str]: + try: + yield "prefetched" + yield "tail" + finally: + source_closed.set() + + response = await anthropic_sse_streaming_response( + body(), + pre_start_error_response=_json_error, + request_id="req_test", + ) + assert isinstance(response, ManagedStreamingResponse) + await bind_response_lifetime(response, release) + + async def fail_on_start(message: Message) -> None: + assert message["type"] == "http.response.start" + raise RuntimeError("send start failed") + + with pytest.raises(RuntimeError, match="send start failed"): + await _serve(response, send=fail_on_start) + + assert source_closed.is_set() + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_first_body_send_failure_closes_prefetched_tail_and_releases() -> None: + release = AsyncMock() + source_closed = asyncio.Event() + + async def body() -> AsyncGenerator[str]: + try: + yield "prefetched" + yield "tail" + finally: + source_closed.set() + + response = await anthropic_sse_streaming_response( + body(), + pre_start_error_response=_json_error, + request_id="req_test", + ) + assert isinstance(response, ManagedStreamingResponse) + await bind_response_lifetime(response, release) + + async def fail_on_first_body(message: Message) -> None: + if message["type"] == "http.response.body": + raise RuntimeError("send body failed") + + with pytest.raises(RuntimeError, match="send body failed"): + await _serve(response, send=fail_on_first_body) + + assert source_closed.is_set() + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_asgi_23_correlation_boundary_preserves_response_start_cleanup() -> None: + release = AsyncMock() + source_closed = asyncio.Event() + + async def body() -> AsyncGenerator[str]: + try: + yield "prefetched" + yield "tail" + finally: + source_closed.set() + + response = await anthropic_sse_streaming_response( + body(), + pre_start_error_response=_json_error, + request_id="req_test", + ) + assert isinstance(response, ManagedStreamingResponse) + await bind_response_lifetime(response, release) + + async def app(scope, receive, send) -> None: + await response(scope, receive, send) + + async def receive() -> Message: + await asyncio.Event().wait() + raise AssertionError("unreachable") + + async def fail_on_start(message: Message) -> None: + assert message["type"] == "http.response.start" + raise OSError("client disconnected") + + scope = _http_scope() + scope["asgi"]["spec_version"] = "2.3" + + with pytest.raises(OSError, match="client disconnected"): + await RequestCorrelationMiddleware(app)(scope, receive, fail_on_start) + + assert source_closed.is_set() + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_first_body_source_failure_releases_response_lifetime() -> None: + release = AsyncMock() + response = ManagedStreamingResponse(_body_raises(RuntimeError("source failed"))) + await bind_response_lifetime(response, release) + + with pytest.raises(RuntimeError, match="source failed"): + await _serve(response) + + release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_repeated_cancellation_waits_for_close_and_release_completion() -> None: + close_started = asyncio.Event() + allow_close = asyncio.Event() + close_finished = asyncio.Event() + release_started = asyncio.Event() + allow_release = asyncio.Event() + release_finished = asyncio.Event() + + class GatedBody: + def __aiter__(self): + return self + + async def __anext__(self) -> str: + raise StopAsyncIteration + + async def aclose(self) -> None: + close_started.set() + await allow_close.wait() + close_finished.set() + + async def release() -> None: + release_started.set() + await allow_release.wait() + release_finished.set() + + response = ManagedStreamingResponse(GatedBody()) + await bind_response_lifetime(response, release) + closing = asyncio.create_task(response.aclose()) + await close_started.wait() + + closing.cancel() + allow_close.set() + await release_started.wait() + closing.cancel() + allow_release.set() + + with pytest.raises(asyncio.CancelledError): + await closing + + assert close_finished.is_set() + assert release_finished.is_set() + + +@pytest.mark.asyncio +async def test_repeated_pre_start_cancellation_waits_for_body_close() -> None: + iteration_started = asyncio.Event() + close_started = asyncio.Event() + allow_close = asyncio.Event() + close_finished = asyncio.Event() + + class GatedPreStartBody: + def __aiter__(self): + return self + + async def __anext__(self) -> str: + iteration_started.set() + await asyncio.Event().wait() + raise AssertionError("unreachable") + + async def aclose(self) -> None: + close_started.set() + await allow_close.wait() + close_finished.set() + + response_task = asyncio.create_task( + anthropic_sse_streaming_response( + GatedPreStartBody(), + pre_start_error_response=_json_error, + request_id="req_pre_start_cancel", + ) + ) + await iteration_started.wait() + + response_task.cancel() + await close_started.wait() + response_task.cancel() + allow_close.set() + + with pytest.raises(asyncio.CancelledError): + await response_task + + assert close_finished.is_set() + + +@pytest.mark.asyncio +async def test_cancellation_during_pre_start_error_cleanup_waits_for_close() -> None: + close_started = asyncio.Event() + allow_close = asyncio.Event() + close_finished = asyncio.Event() + + class FailingPreStartBody: + def __aiter__(self): + return self + + async def __anext__(self) -> str: + raise RuntimeError("provider failed") + + async def aclose(self) -> None: + close_started.set() + await allow_close.wait() + close_finished.set() + + response_task = asyncio.create_task( + anthropic_sse_streaming_response( + FailingPreStartBody(), + pre_start_error_response=_json_error, + request_id="req_pre_start_error_cancel", + ) + ) + await close_started.wait() + + response_task.cancel() + allow_close.set() + + with pytest.raises(asyncio.CancelledError): + await response_task + + assert close_finished.is_set() + + +@pytest.mark.asyncio +async def test_cleanup_failures_are_trace_only_and_do_not_replace_success() -> None: + class CloseFails: + def __init__(self) -> None: + self._yielded = False + + def __aiter__(self): + return self + + async def __anext__(self) -> str: + if self._yielded: + raise StopAsyncIteration + self._yielded = True + return "ok" + + async def aclose(self) -> None: + raise RuntimeError("secret close detail") + + release = AsyncMock(side_effect=RuntimeError("secret release detail")) + response = ManagedStreamingResponse(CloseFails()) + await bind_response_lifetime(response, release) + + with ( + patch("free_claude_code.core.trace.trace_event") as close_trace, + patch("free_claude_code.api.response_streams.trace_event") as release_trace, + ): + messages = await _serve(response) + + assert b"".join(message.get("body", b"") for message in messages) == b"ok" + close_trace.assert_called_once() + assert close_trace.call_args.kwargs["owner"] == "ManagedStreamingResponse" + assert close_trace.call_args.kwargs["close_exc_type"] == "RuntimeError" + assert release_trace.call_args.kwargs["operation"] == "release_resource" + trace_blob = " ".join( + str(call) + for call in [*close_trace.call_args_list, *release_trace.call_args_list] + ) + assert "secret close detail" not in trace_blob + assert "secret release detail" not in trace_blob + + +@pytest.mark.asyncio +async def test_body_close_cancellation_propagates_without_releasing() -> None: + class CloseIsCancelled: + def __aiter__(self): + return self + + async def __anext__(self) -> str: + raise StopAsyncIteration + + async def aclose(self) -> None: + raise asyncio.CancelledError + + release = AsyncMock() + response = ManagedStreamingResponse(CloseIsCancelled()) + await bind_response_lifetime(response, release) + + with pytest.raises(asyncio.CancelledError): + await response.aclose() + + release.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_lease_release_cancellation_propagates() -> None: + release = AsyncMock(side_effect=asyncio.CancelledError) + response = ManagedStreamingResponse(_body_chunks([])) + await bind_response_lifetime(response, release) + + with pytest.raises(asyncio.CancelledError): + await response.aclose() + + release.assert_awaited_once() diff --git a/tests/api/test_routes_optimizations.py b/tests/api/test_routes_optimizations.py new file mode 100644 index 0000000..84ec12e --- /dev/null +++ b/tests/api/test_routes_optimizations.py @@ -0,0 +1,186 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from free_claude_code.api.dependencies import get_settings +from free_claude_code.api.ports import ApiServices +from free_claude_code.application.ports import StopResult +from free_claude_code.config.settings import Settings +from tests.api.support import create_test_app + +app = create_test_app() + + +@pytest.fixture +def client(): + return TestClient(app) + + +@pytest.fixture +def mock_settings(): + settings = Settings() + settings.fast_prefix_detection = True + settings.enable_network_probe_mock = True + settings.enable_title_generation_skip = True + return settings + + +def test_create_message_fast_prefix_detection(client, mock_settings): + app.dependency_overrides[get_settings] = lambda: mock_settings + + payload = { + "model": "claude-3-sonnet", + "max_tokens": 100, + "messages": [{"role": "user", "content": "What is the prefix?"}], + } + + with ( + patch( + "free_claude_code.api.optimization_handlers.is_prefix_detection_request", + return_value=(True, "/ask"), + ), + patch( + "free_claude_code.api.optimization_handlers.extract_command_prefix", + return_value="/ask", + ), + ): + response = client.post("/v1/messages", json=payload) + + assert response.status_code == 200 + data = response.json() + assert "/ask" in data["content"][0]["text"] + + app.dependency_overrides.clear() + + +def test_create_message_quota_check_mock(client, mock_settings): + app.dependency_overrides[get_settings] = lambda: mock_settings + + payload = { + "model": "claude-3-sonnet", + "max_tokens": 100, + "messages": [{"role": "user", "content": "quota check"}], + } + + with patch( + "free_claude_code.api.optimization_handlers.is_quota_check_request", + return_value=True, + ): + response = client.post("/v1/messages", json=payload) + + assert response.status_code == 200 + assert "Quota check passed" in response.json()["content"][0]["text"] + + app.dependency_overrides.clear() + + +def test_create_message_title_generation_skip(client, mock_settings): + app.dependency_overrides[get_settings] = lambda: mock_settings + + payload = { + "model": "claude-3-sonnet", + "max_tokens": 100, + "messages": [{"role": "user", "content": "generate title"}], + } + + with patch( + "free_claude_code.api.optimization_handlers.is_title_generation_request", + return_value=True, + ): + response = client.post("/v1/messages", json=payload) + + assert response.status_code == 200 + assert "Conversation" in response.json()["content"][0]["text"] + + app.dependency_overrides.clear() + + +def test_create_message_empty_messages_returns_400(client): + """POST /v1/messages with messages: [] returns 400 invalid_request_error.""" + payload = { + "model": "claude-3-sonnet", + "max_tokens": 100, + "messages": [], + } + response = client.post("/v1/messages", json=payload) + assert response.status_code == 400 + data = response.json() + assert data.get("type") == "error" + assert data.get("error", {}).get("type") == "invalid_request_error" + assert "cannot be empty" in data.get("error", {}).get("message", "") + + +def test_count_tokens_empty_messages_returns_400(client): + """POST /v1/messages/count_tokens with messages: [] matches messages validation.""" + payload = {"model": "claude-3-sonnet", "messages": []} + response = client.post("/v1/messages/count_tokens", json=payload) + assert response.status_code == 400 + data = response.json() + assert data.get("type") == "error" + assert data.get("error", {}).get("type") == "invalid_request_error" + assert "cannot be empty" in data.get("error", {}).get("message", "") + + +def test_count_tokens_endpoint(client): + payload = { + "model": "claude-3-sonnet", + "messages": [{"role": "user", "content": "hello"}], + } + + with patch("free_claude_code.api.routes.get_token_count", return_value=5): + response = client.post("/v1/messages/count_tokens", json=payload) + + assert response.status_code == 200 + assert response.json()["input_tokens"] == 5 + + +def test_count_tokens_error_returns_500(client): + """When get_token_count raises, count_tokens returns 500.""" + payload = { + "model": "claude-3-sonnet", + "messages": [{"role": "user", "content": "hello"}], + } + + with patch( + "free_claude_code.api.routes.get_token_count", + side_effect=RuntimeError("token error"), + ): + response = client.post("/v1/messages/count_tokens", json=payload) + + assert response.status_code == 500 + assert "token error" in response.json()["detail"] + + +def test_stop_cli_with_messaging_workflow(client): + session_control = MagicMock() + session_control.stop_all = AsyncMock(return_value=StopResult(cancelled_count=3)) + services = app.state.services + app.state.services = ApiServices( + requests=services.requests, + admin=services.admin, + tasks=session_control, + ) + + response = client.post("/stop") + + assert response.status_code == 200 + assert response.json()["cancelled_count"] == 3 + session_control.stop_all.assert_awaited_once() + + +def test_stop_cli_fallback_to_manager(client): + session_control = MagicMock() + session_control.stop_all = AsyncMock(return_value=StopResult(source="cli_manager")) + services = app.state.services + app.state.services = ApiServices( + requests=services.requests, + admin=services.admin, + tasks=session_control, + ) + + response = client.post("/stop") + + assert response.status_code == 200 + assert response.json()["source"] == "cli_manager" + session_control.stop_all.assert_awaited_once() diff --git a/tests/api/test_runtime_safe_logging.py b/tests/api/test_runtime_safe_logging.py new file mode 100644 index 0000000..faa6ec8 --- /dev/null +++ b/tests/api/test_runtime_safe_logging.py @@ -0,0 +1,64 @@ +"""Safe default logging tests for the application runtime owner.""" + +import logging +from unittest.mock import patch + +import pytest + +from free_claude_code.config.settings import Settings +from free_claude_code.runtime.application import ApplicationRuntime, best_effort +from free_claude_code.runtime.provider_manager import ProviderRuntimeManager + + +@pytest.mark.asyncio +async def test_messaging_start_failure_default_logs_exclude_traceback(caplog): + settings = Settings().model_copy( + update={ + "messaging_platform": "telegram", + "telegram_bot_token": "t", + "allowed_telegram_user_id": "1", + "log_api_error_tracebacks": False, + } + ) + runtime = ApplicationRuntime( + ProviderRuntimeManager(settings), + transcriber=None, + ) + + with ( + patch( + "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components", + side_effect=RuntimeError("SECRET_RUNTIME_DETAIL"), + ), + caplog.at_level(logging.ERROR), + ): + await runtime._start_messaging_if_configured() + + blob = " | ".join(record.getMessage() for record in caplog.records) + assert "SECRET_RUNTIME_DETAIL" not in blob + assert "exc_type=RuntimeError" in blob + + +@pytest.mark.asyncio +async def test_best_effort_default_logs_exclude_exception_text(caplog): + async def boom(): + raise ValueError("SECRET_SHUTDOWN") + + with caplog.at_level(logging.WARNING): + await best_effort("test_step", boom(), log_verbose_errors=False) + + blob = " | ".join(record.getMessage() for record in caplog.records) + assert "SECRET_SHUTDOWN" not in blob + assert "exc_type=ValueError" in blob + + +@pytest.mark.asyncio +async def test_best_effort_verbose_includes_exception_text(caplog): + async def boom(): + raise ValueError("VISIBLE_SHUTDOWN") + + with caplog.at_level(logging.WARNING): + await best_effort("test_step", boom(), log_verbose_errors=True) + + blob = " | ".join(record.getMessage() for record in caplog.records) + assert "VISIBLE_SHUTDOWN" in blob diff --git a/tests/api/test_safe_logging.py b/tests/api/test_safe_logging.py new file mode 100644 index 0000000..8c71847 --- /dev/null +++ b/tests/api/test_safe_logging.py @@ -0,0 +1,216 @@ +"""Tests that API and SSE logging avoid raw sensitive payloads by default.""" + +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException +from fastapi.responses import JSONResponse + +from free_claude_code.api import request_errors +from free_claude_code.api.handlers import MessagesHandler, TokenCountHandler +from free_claude_code.application import execution +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic import AnthropicStreamLedger +from free_claude_code.core.anthropic.models import Message, MessagesRequest + + +@pytest.mark.asyncio +async def test_create_message_skips_full_payload_debug_log_by_default(): + settings = Settings() + assert settings.log_raw_api_payloads is False + mock_provider = MagicMock() + + async def fake_stream(*_a, **_kw): + yield "event: ping\ndata: {}\n\n" + + mock_provider.stream_response = fake_stream + service = MessagesHandler(settings, provider_resolver=lambda _: mock_provider) + + request = MessagesRequest( + model="claude-3-haiku-20240307", + max_tokens=10, + messages=[Message(role="user", content="secret-user-text")], + ) + + with patch.object(execution.logger, "debug") as mock_debug: + await service.create(request) + + full_payload_calls = [ + c + for c in mock_debug.call_args_list + if c.args and str(c.args[0]) == "FULL_PAYLOAD [{}]: {}" + ] + assert not full_payload_calls + + +@pytest.mark.asyncio +async def test_create_message_logs_full_payload_when_opt_in(): + settings = Settings() + settings.log_raw_api_payloads = True + mock_provider = MagicMock() + + async def fake_stream(*_a, **_kw): + yield "event: ping\ndata: {}\n\n" + + mock_provider.stream_response = fake_stream + service = MessagesHandler(settings, provider_resolver=lambda _: mock_provider) + request = MessagesRequest( + model="claude-3-haiku-20240307", + max_tokens=10, + messages=[Message(role="user", content="visible")], + ) + + with patch.object(execution.logger, "debug") as mock_debug: + await service.create(request) + + keys = [c.args[0] for c in mock_debug.call_args_list if c.args] + assert any(k == "FULL_PAYLOAD [{}]: {}" for k in keys) + + +def test_stream_ledger_default_debug_has_no_serialized_json_content(): + with patch( + "free_claude_code.core.anthropic.streaming.emitter.logger.debug" + ) as mock_debug: + ledger = AnthropicStreamLedger("msg_x", "m", 1, log_raw_events=False) + ledger.message_start() + + assert mock_debug.call_count == 0 + + +def test_stream_ledger_raw_logging_includes_event_body_when_enabled(): + with patch( + "free_claude_code.core.anthropic.streaming.emitter.logger.debug" + ) as mock_debug: + ledger = AnthropicStreamLedger("msg_x", "m", 1, log_raw_events=True) + ledger.message_start() + + assert mock_debug.call_count == 1 + message = str(mock_debug.call_args) + assert "message_start" in message + assert "role" in message + + +def _flatten_log_calls(mock_log) -> str: + parts: list[str] = [] + for call in mock_log.call_args_list: + parts.extend(str(arg) for arg in call.args) + parts.append(repr(call.kwargs)) + return " ".join(parts) + + +@pytest.mark.asyncio +async def test_create_message_unexpected_error_default_logs_exclude_exception_text(): + settings = Settings() + assert settings.log_api_error_tracebacks is False + secret = "upstream-secret-token-abc" + + mock_provider = MagicMock() + + def stream_boom(*_a, **_kw): + raise RuntimeError(secret) + + mock_provider.stream_response = stream_boom + service = MessagesHandler(settings, provider_resolver=lambda _: mock_provider) + request = MessagesRequest( + model="claude-3-haiku-20240307", + max_tokens=10, + messages=[Message(role="user", content="hi")], + ) + + with patch.object(request_errors.logger, "error") as log_err: + response = await service.create(request) + + blob = _flatten_log_calls(log_err) + assert secret not in blob + assert "RuntimeError" in blob + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + assert response.headers["x-should-retry"] == "false" + assert response.body + + +@pytest.mark.asyncio +async def test_create_message_unexpected_error_terminal_json_ignores_status_code(): + """Non-provider stream failures must not leak arbitrary HTTP status attributes.""" + + class WeirdError(Exception): + status_code = 418 + + settings = Settings() + mock_provider = MagicMock() + + def stream_boom(*_a, **_kw): + raise WeirdError("no") + + mock_provider.stream_response = stream_boom + service = MessagesHandler(settings, provider_resolver=lambda _: mock_provider) + request = MessagesRequest( + model="claude-3-haiku-20240307", + max_tokens=10, + messages=[Message(role="user", content="hi")], + ) + + response = await service.create(request) + + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + assert response.headers["x-should-retry"] == "false" + payload = bytes(response.body).decode("utf-8") + assert '"type":"api_error"' in payload + assert '"message":"no"' in payload + + +def test_parse_cli_event_error_logs_metadata_by_default(): + """CLI parser must not log raw error text unless LOG_RAW_CLI_DIAGNOSTICS is on.""" + from free_claude_code.messaging.event_parser import parse_cli_event + + secret = "user-secret-parser-leak-xyz" + with patch("free_claude_code.messaging.event_parser.logger.info") as log_info: + parse_cli_event( + {"type": "error", "error": {"message": secret}}, log_raw_cli=False + ) + flat = " ".join(str(c) for c in log_info.call_args_list) + assert secret not in flat + assert "message_chars" in flat + + +def test_parse_cli_event_error_logs_text_when_log_raw_cli_enabled(): + from free_claude_code.messaging.event_parser import parse_cli_event + + secret = "visible-cli-parser-msg" + with patch("free_claude_code.messaging.event_parser.logger.info") as log_info: + parse_cli_event( + {"type": "error", "error": {"message": secret}}, log_raw_cli=True + ) + flat = " ".join(str(c) for c in log_info.call_args_list) + assert secret in flat + + +def test_count_tokens_unexpected_error_default_logs_exclude_exception_text(): + settings = Settings() + assert settings.log_api_error_tracebacks is False + secret = "count-tokens-leak-xyz" + + def boom(*_a, **_kw): + raise ValueError(secret) + + service = TokenCountHandler( + settings, + token_counter=boom, + ) + from free_claude_code.core.anthropic.models import TokenCountRequest + + req = TokenCountRequest( + model="claude-3-haiku-20240307", + messages=[Message(role="user", content="x")], + ) + + with ( + patch.object(request_errors.logger, "error") as log_err, + pytest.raises(HTTPException), + ): + service.count(req) + + blob = _flatten_log_calls(log_err) + assert secret not in blob + assert "ValueError" in blob diff --git a/tests/api/test_validation_log.py b/tests/api/test_validation_log.py new file mode 100644 index 0000000..7d26967 --- /dev/null +++ b/tests/api/test_validation_log.py @@ -0,0 +1,33 @@ +"""Tests for validation log summaries (metadata only).""" + +from free_claude_code.api.validation_log import summarize_request_validation_body + + +def test_summarize_lists_block_metadata_without_echoing_string_content(): + body = { + "messages": [ + { + "role": "user", + "content": "secret user phrase", + } + ], + "tools": [{"name": "web_search", "type": "web_search_20250305"}], + } + summary, tool_names = summarize_request_validation_body(body) + assert summary == [ + { + "role": "user", + "content_kind": "str", + "content_length": 18, + } + ] + assert tool_names == ["web_search"] + blob = repr(summary) + repr(tool_names) + assert "secret" not in blob + + +def test_summarize_handles_non_dict_messages_and_missing_tools(): + body = {"messages": ["not_a_dict"]} + summary, tool_names = summarize_request_validation_body(body) + assert summary == [{"message_kind": "str"}] + assert tool_names == [] diff --git a/tests/api/test_web_server_tools.py b/tests/api/test_web_server_tools.py new file mode 100644 index 0000000..d660440 --- /dev/null +++ b/tests/api/test_web_server_tools.py @@ -0,0 +1,770 @@ +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +from fastapi.responses import JSONResponse, StreamingResponse + +import free_claude_code.api.web_tools.constants as web_tool_constants +from free_claude_code.api.handlers import MessagesHandler +from free_claude_code.api.web_tools import egress as web_egress +from free_claude_code.api.web_tools.egress import ( + WebFetchEgressPolicy, + WebFetchEgressViolation, + enforce_web_fetch_egress, +) +from free_claude_code.api.web_tools.outbound import ( + _drain_response_body_capped, + _read_response_body_capped, + _run_web_fetch, +) +from free_claude_code.api.web_tools.request import is_web_server_tool_request +from free_claude_code.api.web_tools.streaming import stream_web_server_tool_response +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.application.routing import ( + ModelRouter, + ResolvedModel, + RoutedMessagesRequest, +) +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic.models import Message, MessagesRequest, Tool +from free_claude_code.core.anthropic.stream_contracts import ( + assert_anthropic_stream_contract, + parse_sse_text, + text_content, +) +from free_claude_code.core.version import package_version +from free_claude_code.messaging.event_parser import parse_cli_event + +_STRICT_EGRESS = WebFetchEgressPolicy( + allow_private_network_targets=False, + allowed_schemes=frozenset({"http", "https"}), +) +_PROVIDER_IDS = tuple(PROVIDER_CATALOG) + + +def test_web_tool_user_agent_reports_installed_package_version() -> None: + assert { + "User-Agent": (f"Mozilla/5.0 compatible; free-claude-code/{package_version()}") + } == web_tool_constants._WEB_TOOL_HTTP_HEADERS + + +class FixedProviderModelRouter(ModelRouter): + """Test double that pins provider identity.""" + + def __init__(self, settings: Settings, provider_id: str) -> None: + super().__init__(settings) + self._fixed_provider_id = provider_id + + def resolve_messages_request( + self, request: MessagesRequest + ) -> RoutedMessagesRequest: + resolved = ResolvedModel( + original_model=request.model, + provider_id=self._fixed_provider_id, + provider_model=request.model, + provider_model_ref=f"{self._fixed_provider_id}/{request.model}", + thinking_enabled=False, + ) + routed = request.model_copy(deep=True) + routed.model = resolved.provider_model + return RoutedMessagesRequest(request=routed, resolved=resolved) + + +def test_web_server_tool_not_detected_when_tool_only_listed(): + """Listing web_search without forcing it must not skip the upstream provider.""" + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[Message(role="user", content="search")], + tools=[Tool(name="web_search", type="web_search_20250305")], + ) + + assert not is_web_server_tool_request(request) + + +def test_web_server_tool_detected_when_tool_choice_forces_it(): + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[Message(role="user", content="search")], + tools=[Tool(name="web_search", type="web_search_20250305")], + tool_choice={"type": "tool", "name": "web_search"}, + ) + + assert is_web_server_tool_request(request) + + +def test_web_server_tool_not_detected_when_forced_name_missing_from_tools(): + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[Message(role="user", content="hi")], + tools=[Tool(name="other", type="function")], + tool_choice={"type": "tool", "name": "web_search"}, + ) + + assert not is_web_server_tool_request(request) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("provider_id", _PROVIDER_IDS) +async def test_service_rejects_forced_server_tool_when_local_handler_is_disabled( + provider_id: str, +): + """Every provider needs FCC's local handler for forced server tools.""" + settings = Settings() + assert settings.enable_web_server_tools is False + service = MessagesHandler( + settings, + provider_resolver=lambda _: MagicMock(), + model_router=FixedProviderModelRouter(settings, provider_id), + ) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[ + Message( + role="user", + content="Perform a web search for the query: DeepSeek V4 model release 2026", + ) + ], + tools=[Tool(name="web_search", type="web_search_20250305")], + tool_choice={"type": "tool", "name": "web_search"}, + ) + with pytest.raises(InvalidRequestError, match="ENABLE_WEB_SERVER_TOOLS"): + await service.create(request) + + +@pytest.mark.parametrize( + "url", + [ + "http://127.0.0.1/", + "http://192.168.1.1/", + "http://10.0.0.1/", + "http://[::1]/", + "http://localhost/foo", + "http://mybox.local/", + "file:///etc/passwd", + "http://169.254.169.254/latest/meta-data/", + ], +) +def test_enforce_web_fetch_egress_blocks_internal_or_disallowed(url: str): + with pytest.raises(WebFetchEgressViolation): + enforce_web_fetch_egress(url, _STRICT_EGRESS) + + +def test_enforce_web_fetch_egress_allows_global_literal_ip(): + enforce_web_fetch_egress("http://8.8.8.8/", _STRICT_EGRESS) + + +def test_enforce_web_fetch_egress_skips_private_checks_when_opted_in(): + enforce_web_fetch_egress( + "http://127.0.0.1/", + WebFetchEgressPolicy( + allow_private_network_targets=True, + allowed_schemes=frozenset({"http", "https"}), + ), + ) + + +def _cm(mock_client: MagicMock) -> MagicMock: + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=mock_client) + cm.__aexit__ = AsyncMock(return_value=None) + return cm + + +def _stream_cm(response: httpx.Response) -> MagicMock: + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=response) + cm.__aexit__ = AsyncMock(return_value=None) + return cm + + +def _json_body(response: JSONResponse) -> dict[str, Any]: + payload = json.loads(bytes(response.body).decode("utf-8")) + assert isinstance(payload, dict) + return payload + + +async def _streaming_body_text(response: StreamingResponse) -> str: + parts = [ + chunk.decode("utf-8") if isinstance(chunk, bytes) else str(chunk) + async for chunk in response.body_iterator + ] + return "".join(parts) + + +def _aiohttp_response( + status: int, + *, + url: str = "http://8.8.8.8/", + location: str | None = None, + body: bytes = b"hello world", +) -> MagicMock: + r = MagicMock() + r.status = status + r.url = url + hdrs: dict[str, str] = {} + if location is not None: + hdrs["location"] = location + r.headers = hdrs + r.get_encoding = MagicMock(return_value="utf-8") + r.raise_for_status = MagicMock() + r.request_info = MagicMock() + r.history = () + + async def iter_chunked(_n: int) -> Any: + yield body + + r.content.iter_chunked = MagicMock(side_effect=iter_chunked) + return r + + +def _aiohttp_client_session_patch( + *responses: MagicMock, +) -> tuple[MagicMock, MagicMock]: + """Build ``ClientSession`` mock that serves ``responses`` to successive ``get`` calls.""" + queue = list(responses) + n = 0 + + def get_side(*_a: Any, **_k: Any) -> Any: + nonlocal n + resp = queue[n] if n < len(queue) else queue[-1] + n += 1 + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=resp) + cm.__aexit__ = AsyncMock(return_value=None) + return cm + + session = MagicMock() + session.get = MagicMock(side_effect=get_side) + + client_cm = MagicMock() + client_cm.__aenter__ = AsyncMock(return_value=session) + client_cm.__aexit__ = AsyncMock(return_value=None) + return client_cm, session + + +def test_enforce_web_fetch_egress_documents_connect_time_pinning(): + assert enforce_web_fetch_egress.__doc__ and "resolved addresses" in ( + enforce_web_fetch_egress.__doc__ or "" + ) + assert ( + web_egress.get_validated_stream_addrinfos_for_egress.__doc__ + and "pinning" + in (web_egress.get_validated_stream_addrinfos_for_egress.__doc__ or "") + ) + assert "DNS-pinned" in (_run_web_fetch.__doc__ or "") + + +@pytest.mark.asyncio +async def test_run_web_fetch_follows_redirect_when_each_hop_is_allowed(): + res_redirect = _aiohttp_response( + 302, url="http://8.8.8.8/start", location="/final", body=b"" + ) + res_ok = _aiohttp_response(200, url="http://8.8.8.8/final", body=b"hello world") + client_cm, session = _aiohttp_client_session_patch(res_redirect, res_ok) + with patch( + "free_claude_code.api.web_tools.outbound.ClientSession", return_value=client_cm + ): + out = await _run_web_fetch("http://8.8.8.8/start", _STRICT_EGRESS) + + assert out["data"] == "hello world" + assert session.get.call_count == 2 + + +@pytest.mark.asyncio +async def test_run_web_fetch_truncates_large_body_to_byte_cap(monkeypatch): + huge = b"x" * 5000 + res_ok = _aiohttp_response(200, url="http://8.8.8.8/big", body=huge) + client_cm, _ = _aiohttp_client_session_patch(res_ok) + monkeypatch.setattr(web_tool_constants, "_MAX_WEB_FETCH_RESPONSE_BYTES", 100) + with patch( + "free_claude_code.api.web_tools.outbound.ClientSession", return_value=client_cm + ): + out = await _run_web_fetch("http://8.8.8.8/big", _STRICT_EGRESS) + + assert len(out["data"]) <= 100 + assert out["data"] == "x" * 100 + + +@pytest.mark.asyncio +async def test_run_web_fetch_redirect_to_blocked_host_raises(): + res_redirect = _aiohttp_response( + 302, + url="http://8.8.8.8/start", + location="http://127.0.0.1/secret", + body=b"", + ) + client_cm, session = _aiohttp_client_session_patch(res_redirect) + with ( + patch( + "free_claude_code.api.web_tools.outbound.ClientSession", + return_value=client_cm, + ), + pytest.raises(WebFetchEgressViolation), + ): + await _run_web_fetch("http://8.8.8.8/start", _STRICT_EGRESS) + + session.get.assert_called_once() + + +@pytest.mark.asyncio +async def test_run_web_fetch_redirect_without_location_raises(): + res_bad = _aiohttp_response(302, url="http://8.8.8.8/here", body=b"") + client_cm, _ = _aiohttp_client_session_patch(res_bad) + with ( + patch( + "free_claude_code.api.web_tools.outbound.ClientSession", + return_value=client_cm, + ), + pytest.raises(WebFetchEgressViolation, match="missing Location"), + ): + await _run_web_fetch("http://8.8.8.8/here", _STRICT_EGRESS) + + +@pytest.mark.asyncio +async def test_run_web_fetch_excess_redirects_raises(): + res1 = _aiohttp_response(302, url="http://8.8.8.8/a", location="/b", body=b"") + res2 = _aiohttp_response(302, url="http://8.8.8.8/b", location="/c", body=b"") + client_cm, _ = _aiohttp_client_session_patch(res1, res2) + with ( + patch("free_claude_code.api.web_tools.constants._MAX_WEB_FETCH_REDIRECTS", 1), + patch( + "free_claude_code.api.web_tools.outbound.ClientSession", + return_value=client_cm, + ), + pytest.raises(WebFetchEgressViolation, match="exceeded maximum redirects"), + ): + await _run_web_fetch("http://8.8.8.8/a", _STRICT_EGRESS) + + +@pytest.mark.asyncio +async def test_streams_web_search_server_tool_result(monkeypatch): + async def fake_search(query: str) -> list[dict[str, str]]: + assert query == "DeepSeek V4 model release 2026" + return [{"title": "DeepSeek V4 Released", "url": "https://example.com/v4"}] + + monkeypatch.setattr( + "free_claude_code.api.web_tools.outbound._run_web_search", fake_search + ) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[ + Message( + role="user", + content=( + "Perform a web search for the query: DeepSeek V4 model release 2026" + ), + ) + ], + tools=[Tool(name="web_search", type="web_search_20250305")], + tool_choice={"type": "tool", "name": "web_search"}, + ) + + raw = "".join( + [ + event + async for event in stream_web_server_tool_response( + request, input_tokens=42, web_fetch_egress=_STRICT_EGRESS + ) + ] + ) + events = parse_sse_text(raw) + assert_anthropic_stream_contract(events) + starts = [e for e in events if e.event == "content_block_start"] + assert starts[0].data["content_block"]["type"] == "server_tool_use" + assert starts[0].data["content_block"]["name"] == "web_search" + tool_use_id = starts[0].data["content_block"]["id"] + assert starts[1].data["content_block"]["type"] == "web_search_tool_result" + assert starts[1].data["content_block"]["tool_use_id"] == tool_use_id + assert starts[1].data["content_block"]["content"][0]["url"] == ( + "https://example.com/v4" + ) + text_deltas = [ + e + for e in events + if e.event == "content_block_delta" + and e.data.get("delta", {}).get("type") == "text_delta" + ] + assert text_deltas, "summary must be streamed as text_delta" + assert "example.com" in text_content(events) + cli_text: list[str] = [] + for ev in events: + cli_text.extend( + str(p.get("text", "")) + for p in parse_cli_event(ev.data) + if p.get("type") == "text_delta" + ) + assert "example.com" in "".join(cli_text) + deltas = [e for e in events if e.event == "message_delta"] + assert deltas[-1].data["usage"]["server_tool_use"] == {"web_search_requests": 1} + + +@pytest.mark.asyncio +async def test_service_streams_forced_web_search_by_default(monkeypatch): + async def fake_search(_query: str) -> list[dict[str, str]]: + return [{"title": "DeepSeek V4 Released", "url": "https://example.com/v4"}] + + monkeypatch.setattr( + "free_claude_code.api.web_tools.outbound._run_web_search", fake_search + ) + settings = Settings.model_validate({"ENABLE_WEB_SERVER_TOOLS": True}) + provider_resolver = MagicMock() + service = MessagesHandler( + settings, + provider_resolver=provider_resolver, + model_router=FixedProviderModelRouter(settings, _PROVIDER_IDS[0]), + ) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[Message(role="user", content="Search for DeepSeek V4")], + tools=[Tool(name="web_search", type="web_search_20250305")], + tool_choice={"type": "tool", "name": "web_search"}, + ) + + response = await service.create(request) + + assert isinstance(response, StreamingResponse) + assert response.media_type == "text/event-stream" + raw = await _streaming_body_text(response) + assert "event: message_start" in raw + assert "DeepSeek V4 Released" in raw + provider_resolver.assert_not_called() + + +@pytest.mark.asyncio +async def test_service_aggregates_forced_web_search_when_stream_false(monkeypatch): + async def fake_search(_query: str) -> list[dict[str, str]]: + return [{"title": "DeepSeek V4 Released", "url": "https://example.com/v4"}] + + monkeypatch.setattr( + "free_claude_code.api.web_tools.outbound._run_web_search", fake_search + ) + settings = Settings.model_validate({"ENABLE_WEB_SERVER_TOOLS": True}) + provider_resolver = MagicMock() + service = MessagesHandler( + settings, + provider_resolver=provider_resolver, + model_router=FixedProviderModelRouter(settings, _PROVIDER_IDS[0]), + ) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[Message(role="user", content="Search for DeepSeek V4")], + stream=False, + tools=[Tool(name="web_search", type="web_search_20250305")], + tool_choice={"type": "tool", "name": "web_search"}, + ) + + response = await service.create(request) + + assert isinstance(response, JSONResponse) + assert response.headers["content-type"].startswith("application/json") + body = _json_body(response) + assert [block["type"] for block in body["content"]] == [ + "server_tool_use", + "web_search_tool_result", + "text", + ] + assert body["content"][1]["content"][0]["url"] == "https://example.com/v4" + assert "DeepSeek V4 Released" in body["content"][2]["text"] + assert body["usage"]["server_tool_use"] == {"web_search_requests": 1} + provider_resolver.assert_not_called() + + +@pytest.mark.asyncio +async def test_forced_web_fetch_ignores_stale_url_from_prior_user_turns(monkeypatch): + """Only the latest user message supplies the URL (not earlier transcript text).""" + target = "https://new-only.example.com/page" + + async def fake_fetch(url: str, _egress: WebFetchEgressPolicy) -> dict[str, str]: + assert url == target + return { + "url": url, + "title": "T", + "media_type": "text/plain", + "data": "x", + } + + monkeypatch.setattr( + "free_claude_code.api.web_tools.outbound._run_web_fetch", fake_fetch + ) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[ + Message( + role="user", + content="Earlier turn https://stale.com/old-article ignore this", + ), + Message(role="assistant", content="ok"), + Message( + role="user", + content=f"Please fetch {target} for the summary", + ), + ], + tools=[Tool(name="web_fetch", type="web_fetch_20250910")], + tool_choice={"type": "tool", "name": "web_fetch"}, + ) + + raw = "".join( + [ + event + async for event in stream_web_server_tool_response( + request, input_tokens=1, web_fetch_egress=_STRICT_EGRESS + ) + ] + ) + assert target in raw + + +@pytest.mark.asyncio +async def test_service_aggregates_forced_web_fetch_when_stream_false(monkeypatch): + async def fake_fetch(url: str, _egress: WebFetchEgressPolicy) -> dict[str, str]: + return { + "url": url, + "title": "Example Article", + "media_type": "text/plain", + "data": "Article body", + } + + monkeypatch.setattr( + "free_claude_code.api.web_tools.outbound._run_web_fetch", fake_fetch + ) + settings = Settings.model_validate({"ENABLE_WEB_SERVER_TOOLS": True}) + provider_resolver = MagicMock() + service = MessagesHandler( + settings, + provider_resolver=provider_resolver, + model_router=FixedProviderModelRouter(settings, _PROVIDER_IDS[0]), + ) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[Message(role="user", content="Fetch https://example.com/article")], + stream=False, + tools=[Tool(name="web_fetch", type="web_fetch_20250910")], + tool_choice={"type": "tool", "name": "web_fetch"}, + ) + + response = await service.create(request) + + assert isinstance(response, JSONResponse) + assert response.headers["content-type"].startswith("application/json") + body = _json_body(response) + assert [block["type"] for block in body["content"]] == [ + "server_tool_use", + "web_fetch_tool_result", + "text", + ] + assert body["content"][1]["content"]["content"]["title"] == "Example Article" + assert body["content"][2]["text"] == "Article body" + assert body["usage"]["server_tool_use"] == {"web_fetch_requests": 1} + provider_resolver.assert_not_called() + + +@pytest.mark.asyncio +async def test_streams_web_fetch_server_tool_result(monkeypatch): + async def fake_fetch(url: str, _egress: WebFetchEgressPolicy) -> dict[str, str]: + assert url == "https://example.com/article" + return { + "url": url, + "title": "Example Article", + "media_type": "text/plain", + "data": "Article body", + } + + monkeypatch.setattr( + "free_claude_code.api.web_tools.outbound._run_web_fetch", fake_fetch + ) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[ + Message(role="user", content="Fetch https://example.com/article please") + ], + tools=[Tool(name="web_fetch", type="web_fetch_20250910")], + tool_choice={"type": "tool", "name": "web_fetch"}, + ) + + raw = "".join( + [ + event + async for event in stream_web_server_tool_response( + request, input_tokens=42, web_fetch_egress=_STRICT_EGRESS + ) + ] + ) + events = parse_sse_text(raw) + assert_anthropic_stream_contract(events) + starts = [e for e in events if e.event == "content_block_start"] + assert starts[0].data["content_block"]["type"] == "server_tool_use" + tool_use_id = starts[0].data["content_block"]["id"] + assert starts[1].data["content_block"]["type"] == "web_fetch_tool_result" + assert starts[1].data["content_block"]["tool_use_id"] == tool_use_id + assert starts[1].data["content_block"]["content"]["content"]["title"] == ( + "Example Article" + ) + assert any( + e.event == "content_block_delta" + and e.data.get("delta", {}).get("type") == "text_delta" + for e in events + ) + assert "Article body" in text_content(events) + cli_text: list[str] = [] + for ev in events: + cli_text.extend( + str(p.get("text", "")) + for p in parse_cli_event(ev.data) + if p.get("type") == "text_delta" + ) + assert "Article body" in "".join(cli_text) + deltas = [e for e in events if e.event == "message_delta"] + assert deltas[-1].data["usage"]["server_tool_use"] == {"web_fetch_requests": 1} + + +@pytest.mark.asyncio +async def test_streams_web_fetch_error_summary_generic_by_default(monkeypatch): + secret = "sensitive-upstream-token" + + async def boom(_url: str, _egress: WebFetchEgressPolicy) -> dict[str, str]: + raise ValueError(secret) + + monkeypatch.setattr("free_claude_code.api.web_tools.outbound._run_web_fetch", boom) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[ + Message( + role="user", + content="Fetch https://example.com/sensitive-path?x=1 please", + ) + ], + tools=[Tool(name="web_fetch", type="web_fetch_20250910")], + tool_choice={"type": "tool", "name": "web_fetch"}, + ) + + with patch("free_claude_code.api.web_tools.outbound.logger.warning") as log_warn: + raw = "".join( + [ + event + async for event in stream_web_server_tool_response( + request, + input_tokens=1, + web_fetch_egress=_STRICT_EGRESS, + verbose_client_errors=False, + ) + ] + ) + + assert secret not in raw + assert "ValueError" not in raw + assert "Web tool request failed." in raw + err_events = parse_sse_text(raw) + assert_anthropic_stream_contract(err_events) + assert any( + e.event == "content_block_delta" + and e.data.get("delta", {}).get("type") == "text_delta" + for e in err_events + ) + cli_err_text: list[str] = [] + for ev in err_events: + cli_err_text.extend( + str(p.get("text", "")) + for p in parse_cli_event(ev.data) + if p.get("type") == "text_delta" + ) + assert "Web tool request failed." in "".join(cli_err_text) + log_blob = " ".join(str(a) for c in log_warn.call_args_list for a in c.args) + assert secret not in log_blob + assert "example.com" in log_blob + assert "/sensitive-path" not in log_blob + + +@pytest.mark.asyncio +async def test_streams_web_fetch_error_summary_verbose_includes_exception_class( + monkeypatch, +): + async def boom(_url: str, _egress: WebFetchEgressPolicy) -> dict[str, str]: + raise OSError(5, "oops") + + monkeypatch.setattr("free_claude_code.api.web_tools.outbound._run_web_fetch", boom) + request = MessagesRequest( + model="claude-haiku-4-5-20251001", + max_tokens=100, + messages=[Message(role="user", content="Fetch https://example.com/x")], + tools=[Tool(name="web_fetch", type="web_fetch_20250910")], + tool_choice={"type": "tool", "name": "web_fetch"}, + ) + + raw = "".join( + [ + event + async for event in stream_web_server_tool_response( + request, + input_tokens=1, + web_fetch_egress=_STRICT_EGRESS, + verbose_client_errors=True, + ) + ] + ) + assert "OSError" in raw + + +@pytest.mark.asyncio +async def test_read_response_body_capped_truncates_single_oversized_chunk(): + cap = 500 + + async def aiter_bytes(chunk_size=None): + yield b"z" * (cap * 20) + + response = MagicMock() + response.aiter_bytes = aiter_bytes + + out = await _read_response_body_capped(response, cap) + assert len(out) == cap + assert out == b"z" * cap + + +@pytest.mark.asyncio +async def test_drain_response_body_capped_stops_after_first_chunk_when_oversized(): + cap = 300 + chunk_calls = {"n": 0} + + async def aiter_bytes(chunk_size=None): + chunk_calls["n"] += 1 + yield b"y" * (cap * 10) + + response = MagicMock() + response.aiter_bytes = aiter_bytes + + await _drain_response_body_capped(response, cap) + assert chunk_calls["n"] == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("provider_id", _PROVIDER_IDS) +async def test_service_rejects_listed_server_tools_for_every_provider( + provider_id: str, +) -> None: + settings = Settings() + service = MessagesHandler( + settings, + provider_resolver=lambda _: MagicMock(), + model_router=FixedProviderModelRouter(settings, provider_id), + ) + request = MessagesRequest( + model="m", + max_tokens=20, + messages=[Message(role="user", content="q")], + tools=[Tool(name="web_search", type="web_search_20250305")], + ) + with pytest.raises(InvalidRequestError, match="cannot pass listed Anthropic"): + await service.create(request) diff --git a/tests/application/test_execution.py b/tests/application/test_execution.py new file mode 100644 index 0000000..7e87927 --- /dev/null +++ b/tests/application/test_execution.py @@ -0,0 +1,181 @@ +"""Application-owned provider execution contracts.""" + +from collections.abc import AsyncIterator +from unittest.mock import MagicMock + +import pytest + +from free_claude_code.application.execution import ProviderExecutor +from free_claude_code.application.routing import ResolvedModel, RoutedMessagesRequest +from free_claude_code.core.anthropic.models import Message, MessagesRequest +from free_claude_code.core.async_iterators import AsyncCloseable + + +class FakeProvider: + def __init__(self) -> None: + self.preflight_calls: list[tuple[MessagesRequest, bool]] = [] + self.stream_calls: list[dict[str, object]] = [] + self.stream_close_calls = 0 + + def preflight_stream( + self, + request: MessagesRequest, + *, + thinking_enabled: bool, + ) -> None: + self.preflight_calls.append((request, thinking_enabled)) + + async def stream_response( + self, + request: MessagesRequest, + input_tokens: int = 0, + *, + request_id: str | None = None, + thinking_enabled: bool | None = None, + ) -> AsyncIterator[str]: + self.stream_calls.append( + { + "request": request, + "input_tokens": input_tokens, + "request_id": request_id, + "thinking_enabled": thinking_enabled, + } + ) + try: + yield "event: message_stop\ndata: {}\n\n" + finally: + self.stream_close_calls += 1 + + +class FailingPreflightProvider(FakeProvider): + def preflight_stream( + self, + request: MessagesRequest, + *, + thinking_enabled: bool, + ) -> None: + raise ValueError("invalid provider request") + + +class FailingStreamConstructionProvider(FakeProvider): + def stream_response( + self, + request: MessagesRequest, + input_tokens: int = 0, + *, + request_id: str | None = None, + thinking_enabled: bool | None = None, + ) -> AsyncIterator[str]: + raise RuntimeError("stream construction failed") + + +def _routed_request() -> RoutedMessagesRequest: + request = MessagesRequest( + model="provider-model", + messages=[Message(role="user", content="hello")], + ) + return RoutedMessagesRequest( + request=request, + resolved=ResolvedModel( + original_model="gateway-model", + provider_id="provider", + provider_model="provider-model", + provider_model_ref="provider/provider-model", + thinking_enabled=True, + ), + ) + + +@pytest.mark.asyncio +async def test_executor_uses_structural_provider_port_and_preflights_eagerly() -> None: + provider = FakeProvider() + routed = _routed_request() + request = routed.request + executor = ProviderExecutor( + lambda _provider_id: provider, + token_counter=lambda _messages, _system, _tools: 17, + ) + + stream = executor.stream( + routed, + wire_api="messages", + raw_log_label="FULL_PAYLOAD", + raw_log_payload=request.model_dump(), + request_id="req_application", + ) + + assert provider.preflight_calls == [(request, True)] + assert [chunk async for chunk in stream] == ["event: message_stop\ndata: {}\n\n"] + assert provider.stream_calls == [ + { + "request": request, + "input_tokens": 17, + "request_id": "req_application", + "thinking_enabled": True, + } + ] + assert provider.stream_close_calls == 1 + + +@pytest.mark.asyncio +async def test_closing_executor_stream_closes_provider_stream_once() -> None: + provider = FakeProvider() + routed = _routed_request() + executor = ProviderExecutor( + lambda _provider_id: provider, + token_counter=lambda _messages, _system, _tools: 17, + ) + stream = executor.stream( + routed, + wire_api="messages", + raw_log_label="FULL_PAYLOAD", + raw_log_payload={}, + request_id="req_early_close", + ) + + assert await anext(stream) == "event: message_stop\ndata: {}\n\n" + assert isinstance(stream, AsyncCloseable) + await stream.aclose() + + assert provider.stream_close_calls == 1 + + +@pytest.mark.asyncio +async def test_stream_construction_failure_remains_deferred_to_iteration() -> None: + provider = FailingStreamConstructionProvider() + executor = ProviderExecutor( + lambda _provider_id: provider, + token_counter=lambda _messages, _system, _tools: 17, + ) + + stream = executor.stream( + _routed_request(), + wire_api="messages", + raw_log_label="FULL_PAYLOAD", + raw_log_payload={}, + request_id="req_deferred_construction", + ) + + with pytest.raises(RuntimeError, match="stream construction failed"): + await anext(stream) + + +def test_executor_preflight_failure_stays_before_token_count_and_stream() -> None: + provider = FailingPreflightProvider() + token_counter = MagicMock(return_value=17) + executor = ProviderExecutor( + lambda _provider_id: provider, + token_counter=token_counter, + ) + + with pytest.raises(ValueError, match="invalid provider request"): + executor.stream( + _routed_request(), + wire_api="messages", + raw_log_label="FULL_PAYLOAD", + raw_log_payload={}, + request_id="req_application", + ) + + token_counter.assert_not_called() + assert provider.stream_calls == [] diff --git a/tests/application/test_routing.py b/tests/application/test_routing.py new file mode 100644 index 0000000..a343525 --- /dev/null +++ b/tests/application/test_routing.py @@ -0,0 +1,235 @@ +from unittest.mock import patch + +import pytest + +from free_claude_code.application.errors import UnknownProviderError +from free_claude_code.application.routing import ModelRouter +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.config.settings import Settings +from free_claude_code.core.anthropic.models import ( + Message, + MessagesRequest, + TokenCountRequest, +) + + +@pytest.fixture +def settings(): + settings = Settings() + settings.model = "nvidia_nim/fallback-model" + settings.model_opus = None + settings.model_sonnet = None + settings.model_haiku = None + settings.enable_model_thinking = True + settings.enable_opus_thinking = None + settings.enable_sonnet_thinking = None + settings.enable_haiku_thinking = None + return settings + + +def test_model_router_resolves_default_model(settings): + resolved = ModelRouter(settings).resolve("claude-3-opus") + + assert resolved.original_model == "claude-3-opus" + assert resolved.provider_id == "nvidia_nim" + assert resolved.provider_model == "fallback-model" + assert resolved.provider_model_ref == "nvidia_nim/fallback-model" + assert resolved.thinking_enabled is True + + +def test_model_router_applies_opus_override(settings): + settings.model_opus = "open_router/deepseek/deepseek-r1" + + request = MessagesRequest( + model="claude-opus-4-20250514", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + routed = ModelRouter(settings).resolve_messages_request(request) + + assert routed.request.model == "deepseek/deepseek-r1" + assert routed.resolved.provider_model_ref == "open_router/deepseek/deepseek-r1" + assert routed.resolved.original_model == "claude-opus-4-20250514" + assert routed.resolved.thinking_enabled is True + assert request.model == "claude-opus-4-20250514" + + +def test_model_router_resolves_per_model_thinking(settings): + settings.enable_model_thinking = False + settings.enable_opus_thinking = True + settings.enable_haiku_thinking = False + + router = ModelRouter(settings) + + assert router.resolve("claude-opus-4-20250514").thinking_enabled is True + assert router.resolve("claude-sonnet-4-20250514").thinking_enabled is False + assert router.resolve("claude-3-haiku-20240307").thinking_enabled is False + assert router.resolve("claude-2.1").thinking_enabled is False + + +def test_model_router_applies_haiku_override(settings): + settings.model_haiku = "lmstudio/qwen2.5-7b" + + routed = ModelRouter(settings).resolve_messages_request( + MessagesRequest( + model="claude-3-haiku-20240307", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + ) + + assert routed.request.model == "qwen2.5-7b" + assert routed.resolved.provider_model_ref == "lmstudio/qwen2.5-7b" + + +def test_model_router_applies_sonnet_override(settings): + settings.model_sonnet = "nvidia_nim/meta/llama-3.3-70b-instruct" + + routed = ModelRouter(settings).resolve_messages_request( + MessagesRequest( + model="claude-sonnet-4-20250514", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + ) + + assert routed.request.model == "meta/llama-3.3-70b-instruct" + assert ( + routed.resolved.provider_model_ref == "nvidia_nim/meta/llama-3.3-70b-instruct" + ) + + +def test_model_router_routes_prefixed_provider_model_directly(settings): + routed = ModelRouter(settings).resolve_messages_request( + MessagesRequest( + model="deepseek/deepseek-chat", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + ) + + assert routed.request.model == "deepseek-chat" + assert routed.resolved.original_model == "deepseek/deepseek-chat" + assert routed.resolved.provider_id == "deepseek" + assert routed.resolved.provider_model == "deepseek-chat" + assert routed.resolved.provider_model_ref == "deepseek/deepseek-chat" + + +def test_model_router_routes_wafer_provider_model_directly(settings): + routed = ModelRouter(settings).resolve_messages_request( + MessagesRequest( + model="wafer/DeepSeek-V4-Pro", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + ) + + assert routed.request.model == "DeepSeek-V4-Pro" + assert routed.resolved.provider_id == "wafer" + assert routed.resolved.provider_model == "DeepSeek-V4-Pro" + assert routed.resolved.provider_model_ref == "wafer/DeepSeek-V4-Pro" + + +def test_model_router_routes_minimax_provider_model_directly(settings): + routed = ModelRouter(settings).resolve_messages_request( + MessagesRequest( + model="minimax/MiniMax-M3", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + ) + + assert routed.request.model == "MiniMax-M3" + assert routed.resolved.provider_id == "minimax" + assert routed.resolved.provider_model == "MiniMax-M3" + assert routed.resolved.provider_model_ref == "minimax/MiniMax-M3" + + +def test_model_router_routes_gateway_encoded_provider_model_directly(settings): + routed = ModelRouter(settings).resolve_messages_request( + MessagesRequest( + model="anthropic/nvidia_nim/deepseek-ai/deepseek-v4-pro", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + ) + + assert routed.request.model == "deepseek-ai/deepseek-v4-pro" + assert ( + routed.resolved.original_model + == "anthropic/nvidia_nim/deepseek-ai/deepseek-v4-pro" + ) + assert routed.resolved.provider_id == "nvidia_nim" + assert routed.resolved.provider_model == "deepseek-ai/deepseek-v4-pro" + assert ( + routed.resolved.provider_model_ref + == "anthropic/nvidia_nim/deepseek-ai/deepseek-v4-pro" + ) + + +def test_model_router_routes_no_thinking_gateway_model_directly(settings): + settings.enable_model_thinking = True + + routed = ModelRouter(settings).resolve_messages_request( + MessagesRequest( + model="claude-3-freecc-no-thinking/nvidia_nim/deepseek-ai/deepseek-v4-pro", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + ) + + assert routed.request.model == "deepseek-ai/deepseek-v4-pro" + assert ( + routed.resolved.original_model + == "claude-3-freecc-no-thinking/nvidia_nim/deepseek-ai/deepseek-v4-pro" + ) + assert routed.resolved.provider_id == "nvidia_nim" + assert routed.resolved.provider_model == "deepseek-ai/deepseek-v4-pro" + assert routed.resolved.thinking_enabled is False + + +def test_model_router_direct_prefixed_model_uses_provider_model_for_thinking(settings): + settings.enable_model_thinking = False + settings.enable_opus_thinking = True + + resolved = ModelRouter(settings).resolve("open_router/anthropic/claude-opus-4") + + assert resolved.provider_id == "open_router" + assert resolved.provider_model == "anthropic/claude-opus-4" + assert resolved.thinking_enabled is True + + +def test_model_router_routes_token_count_request(settings): + settings.model_haiku = "lmstudio/qwen2.5-7b" + + request = TokenCountRequest( + model="claude-3-haiku-20240307", + messages=[Message(role="user", content="hello")], + ) + routed = ModelRouter(settings).resolve_token_count_request(request) + + assert routed.request.model == "qwen2.5-7b" + assert request.model == "claude-3-haiku-20240307" + + +def test_model_router_logs_mapping(settings): + with patch("free_claude_code.application.routing.logger.debug") as mock_log: + ModelRouter(settings).resolve("claude-2.1") + + mock_log.assert_called() + args = mock_log.call_args[0] + assert "MODEL MAPPING" in args[0] + assert args[1] == "claude-2.1" + assert args[2] == "fallback-model" + + +def test_model_router_preserves_typed_error_for_unknown_mapped_provider(settings): + settings.model = "unknown/model" + + with pytest.raises(UnknownProviderError) as exc_info: + ModelRouter(settings).resolve("claude-2.1") + + supported = "', '".join(PROVIDER_CATALOG) + assert str(exc_info.value) == ( + f"Unknown provider_type: 'unknown'. Supported: '{supported}'" + ) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py new file mode 100644 index 0000000..8bcdb22 --- /dev/null +++ b/tests/cli/test_cli.py @@ -0,0 +1,768 @@ +"""Tests for cli/ module.""" + +import asyncio +import os +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.messaging.event_parser import parse_cli_event + +# --- Existing Parser Tests --- + + +class TestCLIParser: + """Test CLI event parsing.""" + + def test_parse_text_content(self): + """Test parsing text content from assistant message.""" + event = { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Hello, world!"}]}, + } + result = parse_cli_event(event) + assert len(result) == 1 + assert result[0]["type"] == "text_chunk" + assert result[0]["text"] == "Hello, world!" + + def test_parse_thinking_content(self): + """Test parsing thinking content.""" + event = { + "type": "assistant", + "message": { + "content": [{"type": "thinking", "thinking": "Let me think..."}] + }, + } + result = parse_cli_event(event) + assert len(result) == 1 + assert result[0]["type"] == "thinking_chunk" + assert ( + result[0]["text"] == "Let me think...\n" + or result[0]["text"] == "Let me think..." + ) + + def test_parse_multiple_content(self): + """Test parsing mixed content (thinking + tools).""" + event = { + "type": "assistant", + "message": { + "content": [ + {"type": "thinking", "thinking": "Thinking..."}, + {"type": "tool_use", "name": "ls", "input": {}}, + ] + }, + } + result = parse_cli_event(event) + assert len(result) == 2 + assert result[0]["type"] == "thinking_chunk" + assert result[0]["text"] == "Thinking..." + assert result[1]["type"] == "tool_use" + + def test_parse_tool_use(self): + """Test parsing tool use content.""" + event = { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "name": "read_file", + "input": {"path": "/test"}, + } + ] + }, + } + result = parse_cli_event(event) + assert len(result) == 1 + assert result[0]["type"] == "tool_use" + assert result[0]["name"] == "read_file" + assert result[0]["input"] == {"path": "/test"} + + def test_parse_text_delta(self): + """Test parsing streaming text delta.""" + event = { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "streaming text"}, + } + result = parse_cli_event(event) + assert len(result) == 1 + assert result[0]["type"] == "text_delta" + assert result[0]["text"] == "streaming text" + + def test_parse_thinking_delta(self): + """Test parsing streaming thinking delta.""" + event = { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "thinking_delta", "thinking": "thinking..."}, + } + result = parse_cli_event(event) + assert len(result) == 1 + assert result[0]["type"] == "thinking_delta" + assert result[0]["text"] == "thinking..." + + def test_parse_error(self): + """Test parsing error event.""" + event = {"type": "error", "error": {"message": "Something went wrong"}} + result = parse_cli_event(event) + assert result[0]["type"] == "error" + assert result[0]["message"] == "Something went wrong" + + def test_parse_exit_success(self): + """Test parsing exit event with success.""" + event = {"type": "exit", "code": 0} + result = parse_cli_event(event) + assert result[0]["type"] == "complete" + assert result[0]["status"] == "success" + + def test_parse_exit_failure(self): + """Test parsing exit event with failure returns an error only.""" + event = {"type": "exit", "code": 1} + result = parse_cli_event(event) + assert len(result) == 1 + assert result[0] == { + "type": "error", + "message": "Process exited with code 1", + "source": "exit", + "exit_code": 1, + } + assert ( + "exit" in result[0]["message"].lower() + or "code" in result[0]["message"].lower() + ) + + def test_parse_invalid_event(self): + """Test parsing returns empty list for unrecognized event.""" + result = parse_cli_event({"type": "unknown"}) + assert result == [] + + def test_parse_non_dict(self): + """Test parsing returns empty list for non-dict input.""" + result = parse_cli_event("not a dict") + assert result == [] + + +# --- CLI Session Tests --- + + +class TestManagedClaudeSession: + """Test ManagedClaudeSession.""" + + def test_session_init(self): + """Test ManagedClaudeSession initialization.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession( + workspace_path="/tmp/test", + proxy_root_url="http://localhost:8082", + allowed_dirs=["/home/user/projects"], + ) + assert session.workspace == os.path.normpath(os.path.abspath("/tmp/test")) + assert session.proxy_root_url == "http://localhost:8082" + assert not session.is_busy + + def test_session_extract_session_id(self): + """Test session ID extraction from various event formats.""" + from free_claude_code.cli.managed.claude import ( + extract_managed_claude_session_id, + ) + + # Direct session_id field + assert extract_managed_claude_session_id({"session_id": "abc123"}) == "abc123" + assert extract_managed_claude_session_id({"sessionId": "abc123"}) == "abc123" + + # Nested in init + assert ( + extract_managed_claude_session_id({"init": {"session_id": "nested123"}}) + == "nested123" + ) + + # Nested in result + assert ( + extract_managed_claude_session_id({"result": {"session_id": "res123"}}) + == "res123" + ) + + # Conversation id + assert ( + extract_managed_claude_session_id({"conversation": {"id": "conv123"}}) + == "conv123" + ) + + # No session ID + assert extract_managed_claude_session_id({"type": "message"}) is None + assert extract_managed_claude_session_id("not a dict") is None + + @pytest.mark.asyncio + async def test_start_task_basic_flow(self): + """Test start_task running a basic command flow.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + # Mock subprocess + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [ + b'{"type": "message", "content": "Hello"}\n', + b'{"session_id": "sess_1"}\n', + b"", # EOF + ] + mock_process.stderr.read.return_value = b"" # No error + mock_process.wait.return_value = 0 + mock_process.returncode = 0 + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = mock_process + + events = [e async for e in session.start_task("Hello")] + + # Verify command construction + # Arg 1 is subprocess command + args = mock_exec.call_args[0] + assert args[0] == "claude" + assert "-p" in args + assert "Hello" in args + + # Verify events + assert ( + len(events) == 4 + ) # message, session_id, session_info (synthesized), exit + assert events[0] == {"type": "message", "content": "Hello"} + assert events[1] == {"type": "session_info", "session_id": "sess_1"} + # The session_info event is yielded by _handle_line_gen right after extracting ID + assert events[2] == {"session_id": "sess_1"} # The original event + assert events[3] == {"type": "exit", "code": 0, "stderr": None} + + assert session.current_session_id == "sess_1" + + @pytest.mark.asyncio + async def test_start_task_with_session_resume(self): + """Test resuming an existing session.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [ + b"", + ] # Immediate EOF + mock_process.stderr.read.return_value = b"" + mock_process.wait.return_value = 0 + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = mock_process + + async for _ in session.start_task("Hello", session_id="sess_abc"): + pass + + args = mock_exec.call_args[0] + assert "--resume" in args + assert "sess_abc" in args + assert "--fork-session" not in args + + @pytest.mark.asyncio + async def test_start_task_with_session_resume_and_fork(self): + """Test resuming an existing session and forking.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [b""] # Immediate EOF + mock_process.stderr.read.return_value = b"" + mock_process.wait.return_value = 0 + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = mock_process + + async for _ in session.start_task( + "Hello", session_id="sess_abc", fork_session=True + ): + pass + + args = mock_exec.call_args[0] + assert "--resume" in args + assert "sess_abc" in args + assert "--fork-session" in args + + @pytest.mark.asyncio + async def test_start_task_process_failure_with_stderr(self): + """Test process exit with error code and stderr output.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [b""] # No stdout + mock_process.stderr.read.side_effect = [b"Fatal error", b""] + mock_process.wait.return_value = 1 + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = mock_process + + events = [e async for e in session.start_task("Hello")] + + # Should have error event from stderr, then exit event + assert len(events) == 2 + assert events[0]["type"] == "error" + assert events[0]["error"]["message"] == "Fatal error" + + assert events[1]["type"] == "exit" + assert events[1]["code"] == 1 + assert events[1]["stderr"] == "Fatal error" + + @pytest.mark.asyncio + async def test_start_task_stderr_while_stdout_streams(self): + """Stderr is drained concurrently so stdout streaming is not blocked.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [ + b'{"type": "message", "content": "Hi"}\n', + b"", + ] + mock_process.stderr.read.side_effect = [b"warning on stderr\n", b""] + mock_process.wait.return_value = 0 + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = mock_process + + events = [e async for e in session.start_task("Hello")] + + assert mock_process.stderr.read.await_count >= 2 + err_events = [e for e in events if e.get("type") == "error"] + assert len(err_events) == 1 + assert "warning on stderr" in err_events[0]["error"]["message"] + assert events[-1]["type"] == "exit" + assert events[-1]["code"] == 0 + + @pytest.mark.asyncio + async def test_start_task_ignores_benign_claude_connectors_stderr(self): + """Known Claude diagnostics on stderr are not surfaced as task failures.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [ + b'{"type": "message", "content": "Hi"}\n', + b"", + ] + mock_process.stderr.read.side_effect = [ + b"claude.ai connectors are disabled in this environment\n", + b"", + ] + mock_process.wait.return_value = 0 + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = mock_process + + events = [e async for e in session.start_task("Hello")] + + assert [e for e in events if e.get("type") == "error"] == [] + assert events[-1] == {"type": "exit", "code": 0, "stderr": None} + + @pytest.mark.asyncio + async def test_start_task_mixed_stderr_reports_only_fatal_lines(self): + """Benign stderr diagnostics are filtered without hiding real failures.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [b""] + mock_process.stderr.read.side_effect = [ + (b"claude.ai connectors are disabled in this environment\nFatal error\n"), + b"", + ] + mock_process.wait.return_value = 1 + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = mock_process + + events = [e async for e in session.start_task("Hello")] + + assert len(events) == 2 + assert events[0] == {"type": "error", "error": {"message": "Fatal error"}} + assert events[1] == {"type": "exit", "code": 1, "stderr": "Fatal error"} + + @pytest.mark.asyncio + async def test_start_task_nonzero_with_only_benign_stderr_has_no_stderr_error( + self, + ): + """A benign stderr line is not duplicated as the process failure reason.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [b""] + mock_process.stderr.read.side_effect = [ + b"claude.ai connectors are disabled in this environment\n", + b"", + ] + mock_process.wait.return_value = 1 + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = mock_process + + events = [e async for e in session.start_task("Hello")] + + assert events == [{"type": "exit", "code": 1, "stderr": None}] + + @pytest.mark.asyncio + async def test_drain_stderr_bounded_retains_cap_but_drains_to_eof(self): + """Oversized stderr is fully drained so the pipe cannot deadlock; capture is bounded.""" + from free_claude_code.cli.managed.session import ( + _MAX_STDERR_CAPTURE_BYTES, + ManagedClaudeSession, + ) + + total_len = _MAX_STDERR_CAPTURE_BYTES + 100_000 + remaining: dict[str, int] = {"n": total_len} + + class _FakeStderr: + async def read(self, n: int = 65536) -> bytes: + left = remaining["n"] + if left <= 0: + return b"" + take = min(n, left) + remaining["n"] = left - take + return b"y" * take + + class _FakeProcess: + stderr = _FakeStderr() + + out = await ManagedClaudeSession._drain_stderr_bounded( + cast(asyncio.subprocess.Process, _FakeProcess()) + ) + assert len(out) == _MAX_STDERR_CAPTURE_BYTES + assert out == b"y" * _MAX_STDERR_CAPTURE_BYTES + assert remaining["n"] == 0 + + @pytest.mark.asyncio + async def test_stop_session(self): + """Test stopping the session process.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = MagicMock() + mock_process.returncode = None # Running + # Mock wait to simulate async finish + mock_process.wait = AsyncMock(return_value=0) + + session.process = mock_process + + with patch( + "free_claude_code.cli.managed.session.kill_pid_tree_best_effort" + ) as kill_tree: + stopped = await session.stop() + + assert stopped is True + kill_tree.assert_called_once_with(mock_process.pid) + mock_process.wait.assert_called() + + @pytest.mark.asyncio + async def test_stop_session_timeout_force_kill(self): + """Test force kill if terminate times out.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = MagicMock() + mock_process.returncode = None + + # First wait times out + async def wait_side_effect(): + if not mock_process.kill.called: + await asyncio.sleep(6) # Should be > 5.0 timeout + return 0 + + # We can simulate timeout by raising TimeoutError directly on first call + mock_process.wait = AsyncMock(side_effect=[asyncio.TimeoutError, 0]) + + session.process = mock_process + + with patch( + "free_claude_code.cli.managed.session.kill_pid_tree_best_effort" + ) as kill_tree: + stopped = await session.stop() + + assert stopped is True + kill_tree.assert_called_once_with(mock_process.pid) + mock_process.kill.assert_called() + + @pytest.mark.asyncio + async def test_start_task_split_buffer(self): + """Test handling of JSON split across chunks.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = AsyncMock() + # Split json: {"type": "mess... age"} + mock_process.stdout.read.side_effect = [ + b'{"type": "mess', + b'age", "content": "Split"}\n', + b"", + ] + mock_process.stderr.read.return_value = b"" + mock_process.wait.return_value = 0 + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = mock_process + + events = [ + e async for e in session.start_task("test") if e["type"] == "message" + ] + + assert len(events) == 1 + assert events[0]["content"] == "Split" + + @pytest.mark.asyncio + async def test_start_task_remnant_buffer(self): + """Test handling of buffer remnant at EOF (no newline at end).""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [ + b'{"type": "message", "content": "Remnant"}', # No newline + b"", + ] + mock_process.stderr.read.return_value = b"" + mock_process.wait.return_value = 0 + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = mock_process + + events = [ + e async for e in session.start_task("test") if e["type"] == "message" + ] + + assert len(events) == 1 + assert events[0]["content"] == "Remnant" + + @pytest.mark.asyncio + async def test_start_task_targets_proxy_root(self): + """Test start_task passes the configured proxy root to Claude Code.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [b""] + mock_process.stderr.read.return_value = b"" + mock_process.wait.return_value = 0 + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = mock_process + async for _ in session.start_task("test"): + pass + + # Check env var + kwargs = mock_exec.call_args[1] + env = kwargs["env"] + assert env["ANTHROPIC_BASE_URL"] == "http://localhost:8082" + + @pytest.mark.asyncio + async def test_start_task_sets_proxy_auth_token(self): + """Test start_task forwards configured proxy auth to Claude Code.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession( + "/tmp", "http://localhost:8082", auth_token="proxy-token" + ) + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [b""] + mock_process.stderr.read.return_value = b"" + mock_process.wait.return_value = 0 + + with ( + patch.dict(os.environ, {"ANTHROPIC_API_KEY": "official-key"}, clear=False), + patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec, + ): + mock_exec.return_value = mock_process + async for _ in session.start_task("test"): + pass + + env = mock_exec.call_args.kwargs["env"] + assert env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token" + assert env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1" + assert env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "190000" + assert "ANTHROPIC_API_KEY" not in env + + @pytest.mark.asyncio + async def test_start_task_uses_sentinel_when_proxy_auth_blank(self): + """Test start_task does not leak inherited Claude auth into proxy calls.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082", auth_token="") + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [b""] + mock_process.stderr.read.return_value = b"" + mock_process.wait.return_value = 0 + + with ( + patch.dict(os.environ, {"ANTHROPIC_AUTH_TOKEN": "stale"}, clear=False), + patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec, + ): + mock_exec.return_value = mock_process + async for _ in session.start_task("test"): + pass + + env = mock_exec.call_args.kwargs["env"] + assert env["ANTHROPIC_AUTH_TOKEN"] == "fcc-no-auth" + + @pytest.mark.asyncio + async def test_start_task_allowed_dirs(self): + """Test start_task includes allowed dirs in command.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession( + "/tmp", "http://localhost:8082", allowed_dirs=["/dir1", "/dir2"] + ) + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [b""] + mock_process.stderr.read.return_value = b"" + mock_process.wait.return_value = 0 + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = mock_process + async for _ in session.start_task("test"): + pass + + cmd = mock_exec.call_args[0] + assert "--add-dir" in cmd + assert os.path.normpath("/dir1") in cmd + assert os.path.normpath("/dir2") in cmd + + @pytest.mark.asyncio + async def test_start_task_json_error(self): + """Test handling of non-JSON output from free_claude_code.cli.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = AsyncMock() + mock_process.stdout.read.side_effect = [b"Not valid json\n", b""] + mock_process.stderr.read.return_value = b"" + mock_process.wait.return_value = 0 + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_exec.return_value = mock_process + + events = [e async for e in session.start_task("test") if e["type"] == "raw"] + + assert len(events) == 1 + assert events[0]["content"] == "Not valid json" + + @pytest.mark.asyncio + async def test_stop_exception(self): + """Test exception handling during stop.""" + from free_claude_code.cli.managed.session import ManagedClaudeSession + + session = ManagedClaudeSession("/tmp", "http://localhost:8082") + + mock_process = MagicMock() + mock_process.returncode = None + + session.process = mock_process + + with patch( + "free_claude_code.cli.managed.session.kill_pid_tree_best_effort", + side_effect=RuntimeError("Permission denied"), + ): + stopped = await session.stop() + assert stopped is False + + +class TestManagedClaudeSessionManager: + """Test ManagedClaudeSessionManager.""" + + @pytest.mark.asyncio + async def test_manager_create_session(self): + """Test creating a new session.""" + from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager + + manager = ManagedClaudeSessionManager( + workspace_path="/tmp/test", + proxy_root_url="http://localhost:8082", + ) + + session, sid, is_new = await manager.get_or_create_session() + assert session is not None + assert sid.startswith("pending_") + assert is_new is True + + @pytest.mark.asyncio + async def test_manager_reuse_session(self): + """Test reusing an existing session.""" + from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager + + manager = ManagedClaudeSessionManager( + workspace_path="/tmp/test", + proxy_root_url="http://localhost:8082", + ) + + # Create first session + s1, sid1, _is_new1 = await manager.get_or_create_session() + + # Request same session + s2, _sid2, is_new2 = await manager.get_or_create_session(session_id=sid1) + + assert s1 is s2 + assert is_new2 is False + + @pytest.mark.asyncio + async def test_manager_stats(self): + """Test manager stats.""" + from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager + + manager = ManagedClaudeSessionManager( + workspace_path="/tmp/test", + proxy_root_url="http://localhost:8082", + ) + + stats = manager.get_stats() + assert stats["active_sessions"] == 0 + assert stats["pending_sessions"] == 0 diff --git a/tests/cli/test_cli_manager_edge_cases.py b/tests/cli/test_cli_manager_edge_cases.py new file mode 100644 index 0000000..a06762f --- /dev/null +++ b/tests/cli/test_cli_manager_edge_cases.py @@ -0,0 +1,126 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.mark.asyncio +async def test_register_real_session_id_moves_pending_to_active_and_maps(): + from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager + + with patch( + "free_claude_code.cli.managed.manager.ManagedClaudeSession" + ) as mock_session_cls: + mock_session = MagicMock() + mock_session.is_busy = False + mock_session.stop = AsyncMock(return_value=True) + mock_session_cls.return_value = mock_session + + manager = ManagedClaudeSessionManager( + workspace_path="/tmp", + proxy_root_url="http://x", + auth_token="proxy-token", + ) + session, temp_id, is_new = await manager.get_or_create_session() + assert session is mock_session + assert is_new is True + mock_session_cls.assert_called_once() + assert mock_session_cls.call_args.kwargs["auth_token"] == "proxy-token" + + ok = await manager.register_real_session_id(temp_id, "real_1") + assert ok is True + + # Lookup via temp id should resolve to the real session id. + s2, sid2, is_new2 = await manager.get_or_create_session(session_id=temp_id) + assert s2 is mock_session + assert sid2 == "real_1" + assert is_new2 is False + + +@pytest.mark.asyncio +async def test_register_real_session_id_missing_temp_id_returns_false(): + from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager + + manager = ManagedClaudeSessionManager( + workspace_path="/tmp", proxy_root_url="http://x" + ) + ok = await manager.register_real_session_id("missing", "real_1") + assert ok is False + + +@pytest.mark.asyncio +async def test_remove_session_pending_stops_and_returns_true(): + from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager + + with patch( + "free_claude_code.cli.managed.manager.ManagedClaudeSession" + ) as mock_session_cls: + mock_session = MagicMock() + mock_session.is_busy = False + mock_session.stop = AsyncMock(return_value=True) + mock_session_cls.return_value = mock_session + + manager = ManagedClaudeSessionManager( + workspace_path="/tmp", proxy_root_url="http://x" + ) + _, temp_id, _ = await manager.get_or_create_session() + + removed = await manager.remove_session(temp_id) + assert removed is True + mock_session.stop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_remove_session_active_removes_temp_mapping(): + from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager + + with patch( + "free_claude_code.cli.managed.manager.ManagedClaudeSession" + ) as mock_session_cls: + mock_session = MagicMock() + mock_session.is_busy = False + mock_session.stop = AsyncMock(return_value=True) + mock_session_cls.return_value = mock_session + + manager = ManagedClaudeSessionManager( + workspace_path="/tmp", proxy_root_url="http://x" + ) + _, temp_id, _ = await manager.get_or_create_session() + await manager.register_real_session_id(temp_id, "real_1") + + removed = await manager.remove_session("real_1") + assert removed is True + + # Temp ID should no longer resolve to an active session after removal. + _, sid2, is_new2 = await manager.get_or_create_session(session_id=temp_id) + assert sid2 == temp_id + assert is_new2 is True + + +@pytest.mark.asyncio +async def test_stop_all_reports_and_retains_stop_exceptions(): + from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager + + manager = ManagedClaudeSessionManager( + workspace_path="/tmp", proxy_root_url="http://x" + ) + + s1 = MagicMock() + s1.stop = AsyncMock(side_effect=RuntimeError("boom")) + s1.is_busy = False + + s2 = MagicMock() + s2.stop = AsyncMock(return_value=True) + s2.is_busy = False + + manager._sessions["a"] = s1 + manager._pending_sessions["b"] = s2 + + with pytest.raises( + RuntimeError, + match=r"^Managed Claude session shutdown failures: 1\.$", + ): + await manager.stop_all() + s1.stop.assert_awaited_once() + s2.stop.assert_awaited_once() + assert manager.get_stats()["active_sessions"] == 1 + assert manager.get_stats()["pending_sessions"] == 0 diff --git a/tests/cli/test_cli_ownership.py b/tests/cli/test_cli_ownership.py new file mode 100644 index 0000000..03a099f --- /dev/null +++ b/tests/cli/test_cli_ownership.py @@ -0,0 +1,17 @@ +from pathlib import Path + +from free_claude_code.cli.managed.session import ManagedClaudeSession + + +def test_cli_session_owns_typed_runner_config(tmp_path: Path) -> None: + session = ManagedClaudeSession( + workspace_path=str(tmp_path), + proxy_root_url="http://127.0.0.1:8082", + allowed_dirs=[str(tmp_path)], + claude_bin="claude-test", + ) + + assert session.config.workspace_path == str(tmp_path) + assert session.config.proxy_root_url == "http://127.0.0.1:8082" + assert session.config.allowed_dirs == [str(tmp_path)] + assert session.config.claude_bin == "claude-test" diff --git a/tests/cli/test_codex_model_catalog.py b/tests/cli/test_codex_model_catalog.py new file mode 100644 index 0000000..d23eaeb --- /dev/null +++ b/tests/cli/test_codex_model_catalog.py @@ -0,0 +1,184 @@ +import json +import os +import shutil +import subprocess +from collections.abc import Mapping +from pathlib import Path +from typing import Any, cast + +import pytest + +from free_claude_code.cli.launchers.codex_model_catalog import ( + build_codex_model_catalog, + write_codex_model_catalog, +) + + +def _models_payload(*model_ids: str) -> dict[str, Any]: + return { + "data": [ + { + "id": model_id, + "display_name": model_id.replace("anthropic/", ""), + } + for model_id in model_ids + ] + } + + +def _catalog_models(catalog: Mapping[str, Any]) -> list[Mapping[str, Any]]: + models = catalog["models"] + assert isinstance(models, list) + catalog_models: list[Mapping[str, Any]] = [] + for model in models: + assert isinstance(model, Mapping) + catalog_models.append(cast(Mapping[str, Any], model)) + return catalog_models + + +def _slugs(catalog: Mapping[str, Any]) -> list[str]: + slugs: list[str] = [] + for model in _catalog_models(catalog): + slug = model["slug"] + assert isinstance(slug, str) + slugs.append(slug) + return slugs + + +def test_codex_catalog_converts_configured_and_cached_models_to_direct_slugs() -> None: + catalog = build_codex_model_catalog( + _models_payload( + "anthropic/nvidia_nim/nvidia/nemotron-3-super", + "claude-3-freecc-no-thinking/nvidia_nim/nvidia/nemotron-3-super", + "anthropic/open_router/meta-llama/llama-3.3-70b", + "claude-3-freecc-no-thinking/open_router/meta-llama/llama-3.3-70b", + ) + ) + + assert _slugs(catalog) == [ + "nvidia_nim/nvidia/nemotron-3-super", + "open_router/meta-llama/llama-3.3-70b", + ] + model = _catalog_models(catalog)[0] + assert { + "slug", + "display_name", + "description", + "default_reasoning_level", + "supported_reasoning_levels", + "shell_type", + "visibility", + "supported_in_api", + "priority", + "additional_speed_tiers", + "service_tiers", + } <= set(model) + + +def test_codex_catalog_excludes_claude_compatibility_model_ids() -> None: + catalog = build_codex_model_catalog( + _models_payload( + "claude-opus-4-20250514", + "claude-3-haiku-20240307", + "anthropic/nvidia_nim/provider-model", + ) + ) + + assert _slugs(catalog) == ["nvidia_nim/provider-model"] + + +def test_codex_catalog_skips_no_thinking_duplicate_when_normal_slug_exists() -> None: + catalog = build_codex_model_catalog( + _models_payload( + "claude-3-freecc-no-thinking/nvidia_nim/provider-model", + "anthropic/nvidia_nim/provider-model", + ) + ) + + assert _slugs(catalog) == ["nvidia_nim/provider-model"] + + +def test_codex_catalog_preserves_no_thinking_only_entries_for_routing() -> None: + catalog = build_codex_model_catalog( + _models_payload("claude-3-freecc-no-thinking/open_router/plain-model") + ) + + assert _slugs(catalog) == ["claude-3-freecc-no-thinking/open_router/plain-model"] + + +def test_codex_catalog_ordering_and_priorities_are_deterministic() -> None: + catalog = build_codex_model_catalog( + _models_payload( + "anthropic/gemini/models/gemini-test", + "anthropic/nvidia_nim/nvidia/test", + "anthropic/gemini/models/gemini-test", + "anthropic/open_router/provider/test", + ) + ) + + models = _catalog_models(catalog) + assert _slugs(catalog) == [ + "gemini/models/gemini-test", + "nvidia_nim/nvidia/test", + "open_router/provider/test", + ] + assert [model["priority"] for model in models] == [0, 1, 2] + + +def test_codex_catalog_accepts_future_direct_provider_slugs() -> None: + catalog = build_codex_model_catalog( + _models_payload( + "nvidia_nim/provider-model", + "anthropic/open_router/provider-model", + ) + ) + + assert _slugs(catalog) == [ + "nvidia_nim/provider-model", + "open_router/provider-model", + ] + + +def test_generated_catalog_schema_is_accepted_by_installed_codex( + tmp_path: Path, +) -> None: + codex_binary = shutil.which("codex") + if codex_binary is None: + pytest.skip("Codex CLI is not installed") + + catalog_path = tmp_path / "codex-model-catalog.json" + write_codex_model_catalog( + catalog_path, + build_codex_model_catalog(_models_payload("anthropic/nvidia_nim/test-model")), + ) + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + codex_env = os.environ.copy() + for key in ( + "CODEX_THREAD_ID", + "CODEX_INTERNAL_ORIGINATOR_OVERRIDE", + "CODEX_SHELL", + "CODEX_PERMISSION_PROFILE", + ): + codex_env.pop(key, None) + codex_env["CODEX_HOME"] = str(codex_home) + + result = subprocess.run( + [ + codex_binary, + "debug", + "models", + "-c", + f"model_catalog_json={json.dumps(str(catalog_path))}", + ], + capture_output=True, + check=False, + encoding="utf-8", + env=codex_env, + errors="replace", + text=True, + timeout=10, + ) + + assert result.returncode == 0, result.stderr + assert "nvidia_nim/test-model" in result.stdout diff --git a/tests/cli/test_entrypoints.py b/tests/cli/test_entrypoints.py new file mode 100644 index 0000000..2de74b2 --- /dev/null +++ b/tests/cli/test_entrypoints.py @@ -0,0 +1,1002 @@ +"""Tests for cli/entrypoints.py — fcc-init scaffolding logic.""" + +import json +import tomllib +from collections.abc import Callable +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch +from urllib.error import URLError +from urllib.request import Request + +import pytest + +from free_claude_code.config.settings import Settings + + +def _launcher_settings( + *, + port: int = 8082, + token: str = "freecc", +) -> Settings: + return Settings.model_construct( + host="0.0.0.0", + port=port, + anthropic_auth_token=token, + model="nvidia_nim/test-model", + ) + + +def _run_init(tmp_home: Path) -> tuple[str, Path]: + """Run init() with home directory redirected to tmp_home. Returns (printed output, env_file path).""" + from free_claude_code.cli.entrypoints import init + + env_file = tmp_home / ".fcc" / ".env" + printed: list[str] = [] + + with ( + patch("pathlib.Path.home", return_value=tmp_home), + patch( + "builtins.print", + side_effect=lambda *a: printed.append(" ".join(str(x) for x in a)), + ), + ): + init() + + return "\n".join(printed), env_file + + +class _JsonResponse: + def __init__(self, payload: dict[str, object]) -> None: + self._payload = payload + + def __enter__(self) -> _JsonResponse: + return self + + def __exit__(self, *_args: object) -> None: + return None + + def read(self) -> bytes: + return json.dumps(self._payload).encode("utf-8") + + +def test_init_creates_env_file(tmp_path: Path) -> None: + """init() creates .env from the bundled template when it doesn't exist yet.""" + output, env_file = _run_init(tmp_path) + + assert env_file.exists() + assert env_file.stat().st_size > 0 + assert str(env_file) in output + + +def test_init_copies_template_content(tmp_path: Path) -> None: + """init() writes the canonical root env.example content, not an empty file.""" + template = (Path(__file__).resolve().parents[2] / ".env.example").read_text( + encoding="utf-8" + ) + _, env_file = _run_init(tmp_path) + + assert env_file.read_text("utf-8") == template + + +def test_init_migrates_home_checkout_env_before_template(tmp_path: Path) -> None: + """init() preserves users who kept config in ~/free-claude-code/.env.""" + legacy_env = tmp_path / "free-claude-code" / ".env" + legacy_env.parent.mkdir(parents=True) + legacy_env.write_text("MODEL=deepseek/deepseek-chat\n", encoding="utf-8") + + output, env_file = _run_init(tmp_path) + + assert env_file.read_text("utf-8") == "MODEL=deepseek/deepseek-chat\n" + assert f"Config migrated from {legacy_env}" in output + + +def test_init_migrates_legacy_xdg_env_before_template(tmp_path: Path) -> None: + """init() preserves users who kept config in ~/.config/free-claude-code/.env.""" + legacy_env = tmp_path / ".config" / "free-claude-code" / ".env" + legacy_env.parent.mkdir(parents=True) + legacy_env.write_text("MODEL=open_router/free-model\n", encoding="utf-8") + + output, env_file = _run_init(tmp_path) + + assert env_file.read_text("utf-8") == "MODEL=open_router/free-model\n" + assert f"Config migrated from {legacy_env}" in output + + +def test_legacy_env_migration_does_not_overwrite_managed_env( + tmp_path: Path, +) -> None: + """Legacy migration never overwrites an existing ~/.fcc/.env.""" + from free_claude_code.cli.entrypoints import _migrate_legacy_env_if_missing + + managed_env = tmp_path / ".fcc" / ".env" + managed_env.parent.mkdir(parents=True) + managed_env.write_text("MODEL=nvidia_nim/current\n", encoding="utf-8") + legacy_env = tmp_path / "free-claude-code" / ".env" + legacy_env.parent.mkdir(parents=True) + legacy_env.write_text("MODEL=deepseek/legacy\n", encoding="utf-8") + + with patch("pathlib.Path.home", return_value=tmp_path): + migrated_from = _migrate_legacy_env_if_missing() + + assert migrated_from is None + assert managed_env.read_text("utf-8") == "MODEL=nvidia_nim/current\n" + + +def test_env_template_loader_uses_root_template_in_source_checkout() -> None: + """Source checkout fallback uses the root .env.example as the single source.""" + from free_claude_code.config.env_template import load_env_template + + template = (Path(__file__).resolve().parents[2] / ".env.example").read_text( + encoding="utf-8" + ) + + assert load_env_template() == template + + +def test_init_creates_parent_directories(tmp_path: Path) -> None: + """init() creates ~/.fcc/ even if it doesn't exist.""" + config_dir = tmp_path / ".fcc" + assert not config_dir.exists() + + _run_init(tmp_path) + + assert config_dir.is_dir() + + +def test_init_skips_if_env_already_exists(tmp_path: Path) -> None: + """init() does not overwrite an existing .env and prints a warning.""" + # Create it first + _run_init(tmp_path) + + env_file = tmp_path / ".fcc" / ".env" + env_file.write_text("existing content", encoding="utf-8") + + output, _ = _run_init(tmp_path) + + assert env_file.read_text("utf-8") == "existing content" + assert "already exists" in output + + +def test_init_prints_next_step_hint(tmp_path: Path) -> None: + """init() tells the user to run fcc-server after editing .env.""" + output, _ = _run_init(tmp_path) + + assert "fcc-server" in output + + +def test_cli_scripts_are_registered() -> None: + pyproject = tomllib.loads( + (Path(__file__).resolve().parents[2] / "pyproject.toml").read_text( + encoding="utf-8" + ) + ) + + scripts = pyproject["project"]["scripts"] + assert scripts["fcc-server"] == "free_claude_code.cli.entrypoints:serve" + assert scripts["free-claude-code"] == "free_claude_code.cli.entrypoints:serve" + assert scripts["fcc-claude"] == "free_claude_code.cli.launchers.claude:launch" + assert scripts["fcc-codex"] == "free_claude_code.cli.launchers.codex:launch" + assert scripts["fcc-pi"] == "free_claude_code.cli.launchers.pi:launch" + + +@pytest.mark.parametrize("entrypoint_name", ["serve", "init"]) +@pytest.mark.parametrize( + "argv", + [("--version",), ("--version", "--help"), ("--help", "--version")], +) +def test_fcc_owned_entrypoints_report_version_without_side_effects( + entrypoint_name: str, + argv: tuple[str, ...], + capsys: pytest.CaptureFixture[str], +) -> None: + from free_claude_code.cli import entrypoints + + with ( + patch.object(entrypoints, "package_version", return_value="9.8.7"), + patch.object(entrypoints, "_migrate_legacy_env_if_missing") as migrate_legacy, + patch.object(entrypoints, "_migrate_config_env_keys") as migrate_keys, + patch.object(entrypoints, "get_settings") as get_settings, + patch.object( + entrypoints, "_run_supervised_server", return_value=False + ) as run_server, + patch.object(entrypoints, "kill_all_best_effort") as kill_all, + patch.object(entrypoints, "config_dir_path") as config_dir, + patch.object(entrypoints, "managed_env_path") as managed_env, + patch.object(entrypoints, "load_env_template") as load_template, + ): + getattr(entrypoints, entrypoint_name)(argv) + + assert capsys.readouterr() == ("free-claude-code 9.8.7\n", "") + for side_effect in { + migrate_legacy, + migrate_keys, + get_settings, + run_server, + kill_all, + config_dir, + managed_env, + load_template, + }: + side_effect.assert_not_called() + + +def test_schedule_open_admin_browser_opens_when_health_ready( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Opening /admin runs after /health preflight succeeds.""" + monkeypatch.delenv("FCC_OPEN_BROWSER", raising=False) + from free_claude_code.cli import entrypoints + from free_claude_code.config.server_urls import local_admin_url + + settings = _launcher_settings(port=31337) + opened_urls: list[str] = [] + + class ImmediateThread: + def __init__(self, target=None, **_kwargs: object) -> None: + self._target = target + + def start(self) -> None: + assert self._target is not None + self._target() + + with ( + patch.object(entrypoints.threading, "Thread", ImmediateThread), + patch.object(entrypoints, "preflight_proxy", return_value=None), + patch.object( + entrypoints.webbrowser, + "open", + side_effect=lambda url: opened_urls.append(url), + ), + patch.object(entrypoints.time, "sleep"), + ): + entrypoints._schedule_open_admin_browser(settings) + + assert opened_urls == [local_admin_url(settings)] + + +def test_schedule_open_admin_browser_skips_when_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("FCC_OPEN_BROWSER", "0") + from free_claude_code.cli import entrypoints + + settings = _launcher_settings() + + with patch.object(entrypoints.threading, "Thread") as thread_cls: + entrypoints._schedule_open_admin_browser(settings) + + thread_cls.assert_not_called() + + +def test_serve_supervisor_restarts_when_app_requests_restart() -> None: + from free_claude_code.cli import entrypoints + + settings = _launcher_settings() + get_settings = MagicMock(side_effect=[settings, settings]) + get_settings.cache_clear = MagicMock() + servers: list[object] = [] + restart_callbacks: list[Callable[[], None]] = [] + + apps: list[SimpleNamespace] = [] + + def build_asgi_app(_settings: Settings, restart_callback: Callable[[], None]): + restart_callbacks.append(restart_callback) + app = SimpleNamespace(runtime=SimpleNamespace(is_closed=False)) + apps.append(app) + return app + + class FakeServer: + def __init__(self, config): + self.config = config + self.should_exit = False + servers.append(self) + + def run(self): + if len(servers) == 1: + restart_callbacks[-1]() + assert self.should_exit is True + self.config.app.runtime.is_closed = True + + def fake_config(app, **kwargs): + return SimpleNamespace(app=app, kwargs=kwargs) + + with ( + patch.object(entrypoints, "get_settings", get_settings), + patch.object(entrypoints.uvicorn, "Config", side_effect=fake_config), + patch.object(entrypoints.uvicorn, "Server", side_effect=FakeServer), + patch.object(entrypoints, "build_asgi_app", side_effect=build_asgi_app), + patch.object(entrypoints, "_schedule_open_admin_browser"), + patch.object(entrypoints, "kill_all_best_effort") as kill_all, + ): + entrypoints.serve() + + assert len(servers) == 2 + get_settings.cache_clear.assert_called_once() + kill_all.assert_called_once() + + +def test_serve_supervisor_refuses_restart_after_incomplete_shutdown() -> None: + from free_claude_code.cli import entrypoints + + settings = _launcher_settings() + get_settings = MagicMock(return_value=settings) + get_settings.cache_clear = MagicMock() + servers: list[object] = [] + restart_callbacks: list[Callable[[], None]] = [] + + def build_asgi_app(_settings: Settings, restart_callback: Callable[[], None]): + restart_callbacks.append(restart_callback) + return SimpleNamespace(runtime=SimpleNamespace(is_closed=False)) + + class FakeServer: + def __init__(self, config): + self.config = config + self.should_exit = False + servers.append(self) + + def run(self): + restart_callbacks[-1]() + assert self.should_exit is True + + def fake_config(app, **kwargs): + return SimpleNamespace(app=app, kwargs=kwargs) + + with ( + patch.object(entrypoints, "get_settings", get_settings), + patch.object(entrypoints.uvicorn, "Config", side_effect=fake_config), + patch.object(entrypoints.uvicorn, "Server", side_effect=FakeServer), + patch.object(entrypoints, "build_asgi_app", side_effect=build_asgi_app), + patch.object(entrypoints, "_schedule_open_admin_browser"), + patch.object(entrypoints, "kill_all_best_effort") as kill_all, + ): + entrypoints.serve() + + assert len(servers) == 1 + get_settings.cache_clear.assert_not_called() + kill_all.assert_called_once() + + +def test_serve_migrates_legacy_env_before_loading_settings(tmp_path: Path) -> None: + from free_claude_code.cli import entrypoints + + legacy_env = tmp_path / "free-claude-code" / ".env" + legacy_env.parent.mkdir(parents=True) + legacy_env.write_text("MODEL=deepseek/deepseek-chat\n", encoding="utf-8") + settings = _launcher_settings() + get_settings = MagicMock(return_value=settings) + get_settings.cache_clear = MagicMock() + + with ( + patch("pathlib.Path.home", return_value=tmp_path), + patch.object(entrypoints, "get_settings", get_settings), + patch.object(entrypoints, "_run_supervised_server", return_value=False), + patch.object(entrypoints, "kill_all_best_effort"), + ): + entrypoints.serve() + + assert (tmp_path / ".fcc" / ".env").read_text("utf-8") == ( + "MODEL=deepseek/deepseek-chat\n" + ) + get_settings.assert_called_once_with() + + +def test_serve_migrates_hf_token_before_loading_settings( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from free_claude_code.cli import entrypoints + + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".env").write_text("HF_TOKEN=legacy-hf\n", encoding="utf-8") + settings = _launcher_settings() + get_settings = MagicMock(return_value=settings) + get_settings.cache_clear = MagicMock() + monkeypatch.chdir(repo) + + with ( + patch("pathlib.Path.home", return_value=tmp_path), + patch.object(entrypoints, "get_settings", get_settings), + patch.object(entrypoints, "_run_supervised_server", return_value=False), + patch.object(entrypoints, "kill_all_best_effort"), + patch.object(entrypoints, "explicit_env_file_huggingface_warning"), + ): + entrypoints.serve() + + assert (repo / ".env").read_text(encoding="utf-8") == ( + "HUGGINGFACE_API_KEY=legacy-hf\n" + ) + get_settings.assert_called_once_with() + + +def test_config_env_key_migration_warns_for_explicit_env_file( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + from free_claude_code.cli import entrypoints + + explicit = tmp_path / "custom.env" + explicit.write_text("HF_TOKEN=legacy-hf\n", encoding="utf-8") + + with patch.dict(entrypoints.os.environ, {"FCC_ENV_FILE": str(explicit)}): + migrated = entrypoints._migrate_config_env_keys() + + assert migrated == () + assert "HF_TOKEN" in capsys.readouterr().err + assert explicit.read_text(encoding="utf-8") == "HF_TOKEN=legacy-hf\n" + + +def test_serve_handles_keyboard_interrupt_without_traceback() -> None: + from free_claude_code.cli import entrypoints + + settings = _launcher_settings() + get_settings = MagicMock(return_value=settings) + get_settings.cache_clear = MagicMock() + + with ( + patch.object(entrypoints, "get_settings", get_settings), + patch.object( + entrypoints, + "_run_supervised_server", + side_effect=KeyboardInterrupt, + ), + patch.object(entrypoints, "kill_all_best_effort") as kill_all, + ): + entrypoints.serve() + + get_settings.cache_clear.assert_not_called() + kill_all.assert_called_once() + + +def test_claude_child_env_targets_current_proxy_config() -> None: + from free_claude_code.cli.claude_env import build_claude_proxy_env + + env = build_claude_proxy_env( + proxy_root_url="http://127.0.0.1:9090", + auth_token=" proxy-token ", + base_env={ + "PATH": "keep", + "ANTHROPIC_API_URL": "https://api.anthropic.com/v1", + "ANTHROPIC_BASE_URL": "https://api.anthropic.com", + "ANTHROPIC_AUTH_TOKEN": "old-token", + "ANTHROPIC_API_KEY": "official-key", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "0", + }, + ) + + assert env["PATH"] == "keep" + assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9090" + assert env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token" + assert env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1" + assert env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "190000" + assert env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] == "1" + assert "ANTHROPIC_API_URL" not in env + assert "ANTHROPIC_API_KEY" not in env + + +def test_claude_child_env_uses_sentinel_for_blank_configured_auth_token() -> None: + from free_claude_code.cli.claude_env import build_claude_proxy_env + + env = build_claude_proxy_env( + proxy_root_url="http://127.0.0.1:8082", + auth_token="", + base_env={ + "ANTHROPIC_AUTH_TOKEN": "inherited-token", + "ANTHROPIC_API_KEY": "official-key", + }, + ) + + assert env["ANTHROPIC_AUTH_TOKEN"] == "fcc-no-auth" + assert "ANTHROPIC_API_KEY" not in env + + +def test_launch_claude_passes_args_and_child_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from free_claude_code.cli.launchers.claude import launch + + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com") + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "old-token") + monkeypatch.setenv("KEEP_ME", "yes") + settings = _launcher_settings(port=9191, token="proxy-token") + + with ( + patch( + "free_claude_code.cli.launchers.claude.get_settings", return_value=settings + ), + patch( + "free_claude_code.cli.launchers.claude.preflight_proxy", return_value=None + ), + patch( + "free_claude_code.cli.launchers.common.shutil.which", + return_value="resolved-claude.cmd", + ), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + patch("free_claude_code.cli.launchers.common.register_pid") as register_pid, + patch("free_claude_code.cli.launchers.common.unregister_pid") as unregister_pid, + pytest.raises(SystemExit) as exc_info, + ): + process = popen.return_value + process.pid = 12345 + process.wait.return_value = 7 + launch(["--model", "sonnet"]) + + assert exc_info.value.code == 7 + popen.assert_called_once() + assert popen.call_args.args[0] == ["resolved-claude.cmd", "--model", "sonnet"] + child_env = popen.call_args.kwargs["env"] + assert child_env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9191" + assert child_env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token" + assert child_env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1" + assert child_env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "190000" + assert child_env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] == "1" + assert child_env["KEEP_ME"] == "yes" + register_pid.assert_called_once_with(12345) + unregister_pid.assert_called_once_with(12345) + + +def test_launch_codex_passes_responses_config_and_child_env( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from free_claude_code.cli.launchers.codex import launch + + monkeypatch.setenv("OPENAI_API_KEY", "official-key") + monkeypatch.setenv("OPENAI_BASE_URL", "https://api.openai.com/v1") + monkeypatch.setenv("CODEX_HOME", "keep-home") + monkeypatch.setenv("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", "Codex Desktop") + monkeypatch.setenv("CODEX_PERMISSION_PROFILE", "danger-full-access") + monkeypatch.setenv("CODEX_SHELL", "1") + monkeypatch.setenv("CODEX_THREAD_ID", "parent-thread") + settings = _launcher_settings(port=9191, token="proxy-token") + catalog_path = tmp_path / "codex-model-catalog.json" + requests: list[Request] = [] + + def fake_urlopen(request: Request, *, timeout: float) -> _JsonResponse: + requests.append(request) + assert timeout == 1.5 + return _JsonResponse( + { + "data": [ + { + "id": "anthropic/nvidia_nim/provider-model", + "display_name": "NVIDIA model", + }, + { + "id": ("claude-3-freecc-no-thinking/nvidia_nim/provider-model"), + "display_name": "NVIDIA model (no thinking)", + }, + { + "id": "claude-opus-4-20250514", + "display_name": "Claude Opus 4", + }, + ] + } + ) + + with ( + patch( + "free_claude_code.cli.launchers.codex.get_settings", return_value=settings + ), + patch( + "free_claude_code.cli.launchers.codex.preflight_proxy", return_value=None + ), + patch( + "free_claude_code.cli.launchers.common.shutil.which", + return_value="resolved-codex.cmd", + ), + patch( + "free_claude_code.cli.launchers.codex.codex_model_catalog_path", + return_value=catalog_path, + ), + patch("free_claude_code.cli.launchers.codex.urlopen", side_effect=fake_urlopen), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + patch("free_claude_code.cli.launchers.common.register_pid") as register_pid, + patch("free_claude_code.cli.launchers.common.unregister_pid") as unregister_pid, + pytest.raises(SystemExit) as exc_info, + ): + process = popen.return_value + process.pid = 12345 + process.wait.return_value = 0 + launch(["exec", "hello"]) + + assert exc_info.value.code == 0 + command = popen.call_args.args[0] + assert command[0] == "resolved-codex.cmd" + assert 'model_provider="fcc"' in command + assert 'model_providers.fcc.base_url="http://127.0.0.1:9191/v1"' in command + assert 'model_providers.fcc.wire_api="responses"' in command + assert f"model_catalog_json={json.dumps(str(catalog_path))}" in command + assert command[-2:] == ["exec", "hello"] + assert len(requests) == 1 + request = requests[0] + assert request.full_url == "http://127.0.0.1:9191/v1/models" + headers = {key.lower(): value for key, value in request.header_items()} + assert headers["x-api-key"] == "proxy-token" + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + assert [model["slug"] for model in catalog["models"]] == [ + "nvidia_nim/provider-model" + ] + child_env = popen.call_args.kwargs["env"] + assert child_env["FCC_CODEX_API_KEY"] == "proxy-token" + assert child_env["CODEX_HOME"] == "keep-home" + assert "CODEX_INTERNAL_ORIGINATOR_OVERRIDE" not in child_env + assert "CODEX_PERMISSION_PROFILE" not in child_env + assert "CODEX_SHELL" not in child_env + assert "CODEX_THREAD_ID" not in child_env + assert "OPENAI_API_KEY" not in child_env + assert "OPENAI_BASE_URL" not in child_env + register_pid.assert_called_once_with(12345) + unregister_pid.assert_called_once_with(12345) + + +def test_launch_codex_catalog_failure_warns_and_continues( + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + from free_claude_code.cli.launchers.codex import launch + + settings = _launcher_settings(port=9191, token="proxy-token") + + with ( + patch( + "free_claude_code.cli.launchers.codex.get_settings", return_value=settings + ), + patch( + "free_claude_code.cli.launchers.codex.preflight_proxy", return_value=None + ), + patch( + "free_claude_code.cli.launchers.common.shutil.which", + return_value="resolved-codex.cmd", + ), + patch( + "free_claude_code.cli.launchers.codex.codex_model_catalog_path", + return_value=tmp_path / "codex-model-catalog.json", + ), + patch( + "free_claude_code.cli.launchers.codex.urlopen", side_effect=URLError("boom") + ), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + patch("free_claude_code.cli.launchers.common.register_pid"), + patch("free_claude_code.cli.launchers.common.unregister_pid"), + pytest.raises(SystemExit) as exc_info, + ): + process = popen.return_value + process.pid = 12345 + process.wait.return_value = 0 + launch(["exec", "hello"]) + + assert exc_info.value.code == 0 + command = popen.call_args.args[0] + assert not any("model_catalog_json=" in arg for arg in command) + captured = capsys.readouterr() + assert "could not prepare Codex model catalog" in captured.err + assert "launching without model picker catalog" in captured.err + + +def test_pi_launcher_builds_scoped_session_command_and_proxy_env( + tmp_path: Path, +) -> None: + from free_claude_code.cli.launchers.pi import ( + build_pi_launcher_command, + build_pi_launcher_env, + ) + + extension = tmp_path / "pi_extension.ts" + env = build_pi_launcher_env( + proxy_root_url="http://127.0.0.1:9191/", + auth_token=" proxy-token ", + base_env={ + "PATH": "keep", + "ANTHROPIC_API_KEY": "native-pi-credential", + "FCC_PI_API_KEY": "stale-key", + "FCC_PI_BASE_URL": "https://stale.invalid", + }, + ) + + assert build_pi_launcher_command( + binary_path="resolved-pi.cmd", + extension_path=extension, + argv=["--print", "hello"], + ) == [ + "resolved-pi.cmd", + "-e", + str(extension), + "--models", + "free-claude-code/**", + "--print", + "hello", + ] + assert env == { + "PATH": "keep", + "ANTHROPIC_API_KEY": "native-pi-credential", + "FCC_PI_BASE_URL": "http://127.0.0.1:9191", + "FCC_PI_API_KEY": "proxy-token", + } + + +def test_pi_launcher_uses_no_auth_sentinel_for_blank_token() -> None: + from free_claude_code.cli.launchers.pi import build_pi_launcher_env + + env = build_pi_launcher_env( + proxy_root_url="http://127.0.0.1:8082", + auth_token="", + base_env={}, + ) + + assert env["FCC_PI_API_KEY"] == "fcc-no-auth" + + +def test_launch_pi_registers_bundled_extension_for_sessions( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from free_claude_code.cli.launchers.pi import launch + + monkeypatch.setenv("KEEP_ME", "yes") + monkeypatch.setenv("FCC_PI_API_KEY", "stale-key") + extension = tmp_path / "pi_extension.ts" + extension.write_text("export default () => {};", encoding="utf-8") + settings = _launcher_settings(port=9191, token="proxy-token") + + with ( + patch("free_claude_code.cli.launchers.pi.get_settings", return_value=settings), + patch("free_claude_code.cli.launchers.pi.preflight_proxy", return_value=None), + patch( + "free_claude_code.cli.launchers.pi.pi_extension_path", + return_value=extension, + ), + patch( + "free_claude_code.cli.launchers.common.shutil.which", + return_value="resolved-pi.cmd", + ), + patch( + "free_claude_code.cli.launchers.pi.pi_binary_is_compatible", + return_value=True, + ), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + patch("free_claude_code.cli.launchers.common.register_pid"), + patch("free_claude_code.cli.launchers.common.unregister_pid"), + pytest.raises(SystemExit) as exc_info, + ): + process = popen.return_value + process.pid = 12345 + process.wait.return_value = 0 + launch(["--print", "hello"]) + + assert exc_info.value.code == 0 + assert popen.call_args.args[0] == [ + "resolved-pi.cmd", + "-e", + str(extension), + "--models", + "free-claude-code/**", + "--print", + "hello", + ] + child_env = popen.call_args.kwargs["env"] + assert child_env["FCC_PI_BASE_URL"] == "http://127.0.0.1:9191" + assert child_env["FCC_PI_API_KEY"] == "proxy-token" + assert child_env["KEEP_ME"] == "yes" + + +@pytest.mark.parametrize( + "argv", + [ + ["--help"], + ["--version"], + ["config", "set", "theme", "dark"], + ["install", "npm:example"], + ["list"], + ["remove", "npm:example"], + ["uninstall", "npm:example"], + ["update"], + ], +) +def test_launch_pi_passes_management_commands_through_without_proxy( + argv: list[str], +) -> None: + from free_claude_code.cli.launchers.pi import launch + + with ( + patch("free_claude_code.cli.launchers.pi.get_settings") as get_settings, + patch("free_claude_code.cli.launchers.pi.preflight_proxy") as preflight, + patch( + "free_claude_code.cli.launchers.common.shutil.which", + return_value="resolved-pi", + ), + patch( + "free_claude_code.cli.launchers.pi.pi_binary_is_compatible", + return_value=True, + ), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + patch("free_claude_code.cli.launchers.common.register_pid"), + patch("free_claude_code.cli.launchers.common.unregister_pid"), + pytest.raises(SystemExit) as exc_info, + ): + process = popen.return_value + process.pid = 12345 + process.wait.return_value = 0 + launch(argv) + + assert exc_info.value.code == 0 + assert popen.call_args.args[0] == ["resolved-pi", *argv] + get_settings.assert_not_called() + preflight.assert_not_called() + + +def test_launch_pi_fails_closed_when_bundled_extension_is_missing( + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + from free_claude_code.cli.launchers.pi import launch + + settings = _launcher_settings(port=9191) + with ( + patch("free_claude_code.cli.launchers.pi.get_settings", return_value=settings), + patch("free_claude_code.cli.launchers.pi.preflight_proxy", return_value=None), + patch( + "free_claude_code.cli.launchers.pi.pi_extension_path", + return_value=tmp_path / "missing.ts", + ), + patch( + "free_claude_code.cli.launchers.common.shutil.which", + return_value="resolved-pi", + ), + patch( + "free_claude_code.cli.launchers.pi.pi_binary_is_compatible", + return_value=True, + ), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + pytest.raises(SystemExit) as exc_info, + ): + launch([]) + + assert exc_info.value.code == 1 + popen.assert_not_called() + assert "bundled Pi extension is missing" in capsys.readouterr().err + + +def test_pi_install_hints_use_official_platform_installers() -> None: + from free_claude_code.cli.launchers.pi import pi_install_hint + + assert "https://pi.dev/install.ps1" in pi_install_hint("win32") + assert "https://pi.dev/install.sh" in pi_install_hint("darwin") + + +@pytest.mark.parametrize( + ("help_output", "return_code", "expected"), + [ + ("--extension \n--models \n", 0, True), + ("--models \n", 0, False), + ("--extension \n", 0, False), + ("--extension \n--models \n", 1, False), + ], +) +def test_pi_binary_compatibility_requires_both_launcher_capabilities( + help_output: str, + return_code: int, + expected: bool, +) -> None: + from free_claude_code.cli.launchers.pi import pi_binary_is_compatible + + with patch( + "free_claude_code.cli.launchers.pi.subprocess.run", + return_value=SimpleNamespace(returncode=return_code, stdout=help_output), + ): + assert pi_binary_is_compatible("resolved-pi") is expected + + +def test_launch_pi_rejects_unrelated_pi_binary( + capsys: pytest.CaptureFixture[str], +) -> None: + from free_claude_code.cli.launchers.pi import launch + + with ( + patch( + "free_claude_code.cli.launchers.common.shutil.which", + return_value="unrelated-pi", + ), + patch( + "free_claude_code.cli.launchers.pi.pi_binary_is_compatible", + return_value=False, + ), + patch("free_claude_code.cli.launchers.pi.get_settings") as get_settings, + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + pytest.raises(SystemExit) as exc_info, + ): + launch([]) + + assert exc_info.value.code == 126 + get_settings.assert_not_called() + popen.assert_not_called() + captured = capsys.readouterr() + assert "not a compatible Pi Coding Agent" in captured.err + assert "https://pi.dev/install." in captured.err + + +def test_launch_claude_keyboard_interrupt_kills_child_tree() -> None: + from free_claude_code.cli.launchers.claude import launch + + settings = _launcher_settings(port=9191, token="proxy-token") + + with ( + patch( + "free_claude_code.cli.launchers.claude.get_settings", return_value=settings + ), + patch( + "free_claude_code.cli.launchers.claude.preflight_proxy", return_value=None + ), + patch( + "free_claude_code.cli.launchers.common.shutil.which", + return_value="resolved-claude.cmd", + ), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + patch("free_claude_code.cli.launchers.common.register_pid"), + patch( + "free_claude_code.cli.launchers.common.kill_pid_tree_best_effort" + ) as kill_tree, + patch("free_claude_code.cli.launchers.common.unregister_pid") as unregister_pid, + pytest.raises(KeyboardInterrupt), + ): + process = popen.return_value + process.pid = 12345 + process.wait.side_effect = [KeyboardInterrupt, 0] + + launch([]) + + kill_tree.assert_called_once_with(12345) + unregister_pid.assert_called_once_with(12345) + + +def test_launch_claude_exits_when_command_cannot_be_resolved( + capsys: pytest.CaptureFixture[str], +) -> None: + from free_claude_code.cli.launchers.claude import launch + + settings = _launcher_settings() + with ( + patch( + "free_claude_code.cli.launchers.claude.get_settings", return_value=settings + ), + patch( + "free_claude_code.cli.launchers.claude.preflight_proxy", return_value=None + ), + patch("free_claude_code.cli.launchers.common.shutil.which", return_value=None), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + pytest.raises(SystemExit) as exc_info, + ): + launch([]) + + assert exc_info.value.code == 127 + popen.assert_not_called() + captured = capsys.readouterr() + assert "Could not find Claude Code command: claude" in captured.err + assert "npm install -g @anthropic-ai/claude-code" in captured.err + + +def test_launch_claude_unreachable_proxy_exits_with_hint( + capsys: pytest.CaptureFixture[str], +) -> None: + from free_claude_code.cli.launchers.claude import launch + + settings = _launcher_settings(port=9393) + with ( + patch( + "free_claude_code.cli.launchers.claude.get_settings", return_value=settings + ), + patch( + "free_claude_code.cli.launchers.claude.preflight_proxy", + return_value="connection refused", + ), + patch("free_claude_code.cli.launchers.common.subprocess.Popen") as popen, + pytest.raises(SystemExit) as exc_info, + ): + launch([]) + + assert exc_info.value.code == 1 + popen.assert_not_called() + captured = capsys.readouterr() + assert "http://127.0.0.1:9393" in captured.err + assert "fcc-server" in captured.err diff --git a/tests/cli/test_managed_claude.py b/tests/cli/test_managed_claude.py new file mode 100644 index 0000000..409ce22 --- /dev/null +++ b/tests/cli/test_managed_claude.py @@ -0,0 +1,213 @@ +import os + +from free_claude_code.cli.claude_env import build_claude_proxy_env +from free_claude_code.cli.managed.claude import ( + MANAGED_CLAUDE_MODEL_TIER, + ManagedClaudeConfig, + ManagedClaudeParseState, + ManagedClaudeTaskRequest, + build_managed_claude_env, + build_managed_claude_invocation, + extract_managed_claude_session_id, + parse_managed_claude_stdout_line, +) +from free_claude_code.cli.managed.diagnostics import classify_managed_claude_stderr + + +def _config(**overrides: object) -> ManagedClaudeConfig: + workspace_path = overrides.get("workspace_path", os.path.normpath("/tmp/workspace")) + proxy_root_url = overrides.get("proxy_root_url", "http://localhost:8082") + raw_allowed_dirs = overrides.get("allowed_dirs") + allowed_dirs: list[str] = [] + if raw_allowed_dirs is not None: + assert isinstance(raw_allowed_dirs, list) + for directory in raw_allowed_dirs: + assert isinstance(directory, str) + allowed_dirs.append(directory) + claude_bin = overrides.get("claude_bin", "claude") + auth_token = overrides.get("auth_token", "proxy-token") + + assert isinstance(workspace_path, str) + assert isinstance(proxy_root_url, str) + assert isinstance(claude_bin, str) + assert isinstance(auth_token, str) + return ManagedClaudeConfig( + workspace_path=workspace_path, + proxy_root_url=proxy_root_url, + allowed_dirs=allowed_dirs, + claude_bin=claude_bin, + auth_token=auth_token, + ) + + +def test_managed_claude_builds_new_task_command_and_env() -> None: + invocation = build_managed_claude_invocation( + config=_config(allowed_dirs=[os.path.normpath("/tmp/extra")]), + request=ManagedClaudeTaskRequest(prompt="hello"), + base_env={"PATH": "keep", "ANTHROPIC_API_KEY": "official"}, + ) + + assert invocation.argv[:4] == ( + "claude", + "--model", + MANAGED_CLAUDE_MODEL_TIER, + "-p", + ) + assert "hello" in invocation.argv + assert "--output-format" in invocation.argv + assert "stream-json" in invocation.argv + assert "--add-dir" in invocation.argv + assert os.path.normpath("/tmp/extra") in invocation.argv + assert "--settings" not in invocation.argv + assert invocation.env["PATH"] == "keep" + assert invocation.env["ANTHROPIC_BASE_URL"] == "http://localhost:8082" + assert invocation.env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token" + assert invocation.env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1" + assert invocation.env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "190000" + assert invocation.env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] == "1" + assert "ANTHROPIC_API_URL" not in invocation.env + assert "ANTHROPIC_API_KEY" not in invocation.env + assert invocation.trace_metadata["client_cli_id"] == "claude" + assert invocation.trace_metadata["claude_binary"] == "claude" + assert invocation.trace_metadata["managed_model_tier"] == MANAGED_CLAUDE_MODEL_TIER + + +def test_managed_claude_builds_resume_and_fork_commands() -> None: + resume = build_managed_claude_invocation( + config=_config(), + request=ManagedClaudeTaskRequest(prompt="again", session_id="sess_1"), + base_env={}, + ) + fork = build_managed_claude_invocation( + config=_config(), + request=ManagedClaudeTaskRequest( + prompt="branch", session_id="sess_1", fork_session=True + ), + base_env={}, + ) + + assert resume.argv[:6] == ( + "claude", + "--resume", + "sess_1", + "--model", + MANAGED_CLAUDE_MODEL_TIER, + "-p", + ) + assert "--fork-session" not in resume.argv + assert fork.argv[:7] == ( + "claude", + "--resume", + "sess_1", + "--fork-session", + "--model", + MANAGED_CLAUDE_MODEL_TIER, + "-p", + ) + assert "--fork-session" in fork.argv + + +def test_managed_claude_uses_native_plan_storage() -> None: + invocation = build_managed_claude_invocation( + config=_config(), + request=ManagedClaudeTaskRequest(prompt="hello"), + base_env={}, + ) + + assert "--settings" not in invocation.argv + + +def test_managed_claude_env_uses_sentinel_when_proxy_auth_blank() -> None: + env = build_managed_claude_env( + proxy_root_url="http://localhost:8082", + auth_token="", + base_env={"ANTHROPIC_AUTH_TOKEN": "stale"}, + ) + + assert env["ANTHROPIC_AUTH_TOKEN"] == "fcc-no-auth" + + +def test_managed_claude_env_only_adds_noninteractive_process_settings() -> None: + base_env = { + "PATH": "keep", + "ANTHROPIC_API_URL": "https://api.anthropic.com/v1", + "ANTHROPIC_API_KEY": "official-key", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "0", + } + proxy_env = build_claude_proxy_env( + proxy_root_url="http://localhost:8082", + auth_token="proxy-token", + base_env=base_env, + ) + + managed_env = build_managed_claude_env( + proxy_root_url="http://localhost:8082", + auth_token="proxy-token", + base_env=base_env, + ) + + assert managed_env == { + **proxy_env, + "TERM": "dumb", + "PYTHONIOENCODING": "utf-8", + } + + +def test_managed_claude_stderr_classifier_filters_known_benign_notice() -> None: + diagnostics = classify_managed_claude_stderr( + "claude.ai connectors are disabled in this environment" + ) + + assert diagnostics.has_benign + assert diagnostics.benign_lines == ( + "claude.ai connectors are disabled in this environment", + ) + assert diagnostics.fatal_text is None + + +def test_managed_claude_stderr_classifier_preserves_unknown_lines() -> None: + diagnostics = classify_managed_claude_stderr( + "claude.ai connectors are disabled in this environment\nFatal error" + ) + + assert diagnostics.has_benign + assert diagnostics.fatal_text == "Fatal error" + + +def test_managed_claude_extracts_session_ids() -> None: + assert extract_managed_claude_session_id({"session_id": "direct"}) == "direct" + assert extract_managed_claude_session_id({"sessionId": "camel"}) == "camel" + assert ( + extract_managed_claude_session_id({"init": {"session_id": "nested"}}) + == "nested" + ) + assert ( + extract_managed_claude_session_id({"result": {"sessionId": "result"}}) + == "result" + ) + assert extract_managed_claude_session_id({"conversation": {"id": "conv"}}) == "conv" + assert extract_managed_claude_session_id({"type": "message"}) is None + assert extract_managed_claude_session_id("not a dict") is None + + +def test_managed_claude_parser_emits_session_info_once() -> None: + state = ManagedClaudeParseState() + + first = list(parse_managed_claude_stdout_line('{"session_id": "sess_1"}', state)) + second = list(parse_managed_claude_stdout_line('{"session_id": "sess_2"}', state)) + + assert first == [ + {"type": "session_info", "session_id": "sess_1"}, + {"session_id": "sess_1"}, + ] + assert second == [{"session_id": "sess_2"}] + + +def test_managed_claude_parser_returns_raw_for_non_json() -> None: + events = list( + parse_managed_claude_stdout_line( + "not json", ManagedClaudeParseState(log_raw_cli_diagnostics=False) + ) + ) + + assert events == [{"type": "raw", "content": "not json"}] diff --git a/tests/cli/test_managed_session_shutdown.py b/tests/cli/test_managed_session_shutdown.py new file mode 100644 index 0000000..465f4f7 --- /dev/null +++ b/tests/cli/test_managed_session_shutdown.py @@ -0,0 +1,471 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager +from free_claude_code.cli.managed.session import ManagedClaudeSession + + +def _manager() -> ManagedClaudeSessionManager: + return ManagedClaudeSessionManager( + workspace_path="/tmp", + proxy_root_url="http://127.0.0.1:8082", + ) + + +def _mock_session(*stop_results: object) -> MagicMock: + session = MagicMock(spec=ManagedClaudeSession) + session.is_busy = False + session.stop = AsyncMock(side_effect=list(stop_results)) + return session + + +def _completed_process(pid: int) -> MagicMock: + process = MagicMock() + process.pid = pid + process.returncode = 0 + process.stdout.read = AsyncMock(return_value=b"") + process.stderr = None + process.wait = AsyncMock(return_value=0) + return process + + +@pytest.mark.asyncio +async def test_stop_is_idempotent_without_a_live_process() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + + assert await session.stop() is True + + process = MagicMock() + process.pid = 101 + process.returncode = 0 + process.wait = AsyncMock() + session.process = process + + with ( + patch( + "free_claude_code.cli.managed.session.kill_pid_tree_best_effort" + ) as kill_tree, + patch("free_claude_code.cli.managed.session.unregister_pid") as unregister, + ): + assert await session.stop() is True + + kill_tree.assert_not_called() + process.wait.assert_not_awaited() + unregister.assert_called_once_with(101) + + +@pytest.mark.asyncio +async def test_stopped_session_reference_cannot_launch_a_new_process() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + + assert await session.stop() is True + + with ( + patch( + "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", + new=AsyncMock(return_value=_completed_process(100)), + ) as create_process, + patch("free_claude_code.cli.managed.session.trace_event") as trace, + pytest.raises(RuntimeError, match="closed"), + ): + async for _ in session.start_task("must not launch"): + pass + + create_process.assert_not_awaited() + trace.assert_not_called() + assert session.is_busy is False + + +@pytest.mark.asyncio +async def test_launch_publication_wins_before_concurrent_stop() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + launch_entered = asyncio.Event() + release_launch = asyncio.Event() + release_stream = asyncio.Event() + process = MagicMock() + process.pid = 102 + process.returncode = None + process.stderr = None + + async def create_process(*_args: object, **_kwargs: object) -> MagicMock: + launch_entered.set() + await release_launch.wait() + return process + + async def read_stdout(*_args: object, **_kwargs: object) -> bytes: + await release_stream.wait() + return b"" + + async def wait_process() -> int: + process.returncode = 0 + return 0 + + process.stdout.read = AsyncMock(side_effect=read_stdout) + process.wait = AsyncMock(side_effect=wait_process) + + with ( + patch( + "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", + side_effect=create_process, + ), + patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"), + patch("free_claude_code.cli.managed.session.register_pid"), + patch("free_claude_code.cli.managed.session.unregister_pid"), + ): + stream_task = asyncio.create_task( + _collect_session_events(session, "launch wins") + ) + await launch_entered.wait() + stop_task = asyncio.create_task(session.stop()) + await asyncio.sleep(0) + stopped_before_publication = stop_task.done() + + release_launch.set() + assert await asyncio.wait_for(stop_task, timeout=1) is True + release_stream.set() + events = await asyncio.wait_for(stream_task, timeout=1) + + with pytest.raises(RuntimeError, match="closed"): + async for _ in session.start_task("cannot relaunch"): + pass + + assert events == [{"type": "exit", "code": 0, "stderr": None}] + assert stopped_before_publication is False + + +@pytest.mark.asyncio +async def test_concurrent_stop_wins_before_launch_publication() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + stop_entered = asyncio.Event() + release_stop = asyncio.Event() + process = MagicMock() + process.pid = 103 + process.returncode = None + + async def wait_process() -> int: + stop_entered.set() + await release_stop.wait() + process.returncode = 0 + return 0 + + process.wait = AsyncMock(side_effect=wait_process) + session.process = process + unexpected_process = _completed_process(106) + + with ( + patch( + "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", + new=AsyncMock(return_value=unexpected_process), + ) as create_process, + patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"), + patch("free_claude_code.cli.managed.session.unregister_pid"), + patch("free_claude_code.cli.managed.session.trace_event") as trace, + ): + stop_task = asyncio.create_task(session.stop()) + await stop_entered.wait() + stream_task = asyncio.create_task(_collect_session_events(session, "stop wins")) + await asyncio.sleep(0) + + release_stop.set() + assert await asyncio.wait_for(stop_task, timeout=1) is True + with pytest.raises(RuntimeError, match="closed"): + await asyncio.wait_for(stream_task, timeout=1) + + create_process.assert_not_awaited() + trace.assert_not_called() + + +@pytest.mark.asyncio +async def test_normal_sequential_starts_remain_allowed_before_stop() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + processes = [_completed_process(104), _completed_process(105)] + + with ( + patch( + "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", + new=AsyncMock(side_effect=processes), + ) as create_process, + patch("free_claude_code.cli.managed.session.register_pid"), + patch("free_claude_code.cli.managed.session.unregister_pid"), + ): + first = await _collect_session_events(session, "first") + second = await _collect_session_events(session, "second") + + assert first == [{"type": "exit", "code": 0, "stderr": None}] + assert second == [{"type": "exit", "code": 0, "stderr": None}] + assert create_process.await_count == 2 + + +async def _collect_session_events( + session: ManagedClaudeSession, + prompt: str, +) -> list[dict]: + return [event async for event in session.start_task(prompt)] + + +@pytest.mark.asyncio +async def test_failed_stop_keeps_pid_registered_until_retry_confirms_exit() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + process = MagicMock() + process.pid = 202 + process.returncode = None + process.wait = AsyncMock(side_effect=[RuntimeError("wait failed"), 0]) + session.process = process + + with ( + patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"), + patch("free_claude_code.cli.managed.session.unregister_pid") as unregister, + ): + assert await session.stop() is False + unregister.assert_not_called() + + assert await session.stop() is True + + unregister.assert_called_once_with(202) + + +@pytest.mark.asyncio +async def test_cancelled_stop_keeps_pid_registered_and_can_be_retried() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + process = MagicMock() + process.pid = 303 + process.returncode = None + wait_entered = asyncio.Event() + + async def wait_until_cancelled() -> int: + wait_entered.set() + await asyncio.Event().wait() + return 0 + + process.wait = AsyncMock(side_effect=wait_until_cancelled) + session.process = process + + with ( + patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"), + patch("free_claude_code.cli.managed.session.unregister_pid") as unregister, + ): + stopping = asyncio.create_task(session.stop()) + await wait_entered.wait() + stopping.cancel() + with pytest.raises(asyncio.CancelledError): + await stopping + unregister.assert_not_called() + + process.wait = AsyncMock(return_value=0) + assert await session.stop() is True + + unregister.assert_called_once_with(303) + + +@pytest.mark.asyncio +async def test_task_failure_keeps_unconfirmed_process_pid_registered() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + process = MagicMock() + process.pid = 404 + process.returncode = None + process.stdout.read = AsyncMock(side_effect=RuntimeError("read failed")) + process.stderr = None + + with ( + patch( + "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", + new=AsyncMock(return_value=process), + ), + patch("free_claude_code.cli.managed.session.register_pid") as register, + patch("free_claude_code.cli.managed.session.unregister_pid") as unregister, + pytest.raises(RuntimeError, match="read failed"), + ): + async for _ in session.start_task("hello"): + pass + + register.assert_called_once_with(404) + unregister.assert_not_called() + + +@pytest.mark.asyncio +async def test_completed_task_wait_unregisters_confirmed_process_pid() -> None: + session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082") + process = MagicMock() + process.pid = 405 + process.returncode = None + process.stdout.read = AsyncMock(return_value=b"") + process.stderr = None + process.wait = AsyncMock(return_value=0) + + with ( + patch( + "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec", + new=AsyncMock(return_value=process), + ), + patch("free_claude_code.cli.managed.session.register_pid") as register, + patch("free_claude_code.cli.managed.session.unregister_pid") as unregister, + ): + events = [event async for event in session.start_task("hello")] + + assert events == [{"type": "exit", "code": 0, "stderr": None}] + register.assert_called_once_with(405) + unregister.assert_called_once_with(405) + + +@pytest.mark.parametrize( + ("first_stop", "raised"), + [ + (False, None), + (RuntimeError("stop failed"), RuntimeError), + (asyncio.CancelledError(), asyncio.CancelledError), + ], +) +@pytest.mark.asyncio +async def test_remove_failure_retains_exact_aliases_and_closes_session_for_reuse( + first_stop: object, + raised: type[BaseException] | None, +) -> None: + manager = _manager() + session = _mock_session(first_stop, True) + manager._sessions["real_1"] = session + manager._temp_to_real["temp_1"] = "real_1" + manager._real_to_temp["real_1"] = "temp_1" + expected_sessions = dict(manager._sessions) + expected_temp_to_real = dict(manager._temp_to_real) + expected_real_to_temp = dict(manager._real_to_temp) + + if raised is None: + assert await manager.remove_session("real_1") is False + else: + with pytest.raises(raised): + await manager.remove_session("real_1") + + assert manager._sessions == expected_sessions + assert manager._temp_to_real == expected_temp_to_real + assert manager._real_to_temp == expected_real_to_temp + with pytest.raises(RuntimeError, match="closing"): + await manager.get_or_create_session("real_1") + with pytest.raises(RuntimeError, match="closing"): + await manager.get_or_create_session("temp_1") + + assert await manager.remove_session("temp_1") is True + assert manager._sessions == {} + assert manager._temp_to_real == {} + assert manager._real_to_temp == {} + assert session.stop.await_count == 2 + + +@pytest.mark.asyncio +async def test_register_rejects_a_pending_session_after_stop_failure() -> None: + manager = _manager() + session = _mock_session(False) + manager._pending_sessions["temp_1"] = session + + assert await manager.remove_session("temp_1") is False + assert await manager.register_real_session_id("temp_1", "real_1") is False + assert manager._pending_sessions == {"temp_1": session} + assert manager._sessions == {} + assert manager._temp_to_real == {} + assert manager._real_to_temp == {} + + +@pytest.mark.asyncio +async def test_register_rejects_real_id_owned_by_a_different_session() -> None: + manager = _manager() + existing = _mock_session(False) + pending = _mock_session(True) + manager._sessions["real_1"] = existing + manager._pending_sessions["temp_2"] = pending + + assert await manager.register_real_session_id("temp_2", "real_1") is False + + assert manager._sessions == {"real_1": existing} + assert manager._pending_sessions == {"temp_2": pending} + assert manager._temp_to_real == {} + assert manager._real_to_temp == {} + + +@pytest.mark.asyncio +async def test_stop_all_never_loses_a_closing_owner_outside_identity_maps() -> None: + manager = _manager() + orphaned_owner = _mock_session(False) + mapped_owner = _mock_session(True) + manager._closing_sessions.add(orphaned_owner) + manager._sessions["real_1"] = mapped_owner + + with pytest.raises( + RuntimeError, + match=r"^Managed Claude session shutdown failures: 1\.$", + ): + await manager.stop_all() + + orphaned_owner.stop.assert_awaited_once() + mapped_owner.stop.assert_awaited_once() + assert manager._sessions == {} + assert manager._closing_sessions == {orphaned_owner} + + orphaned_owner.stop = AsyncMock(return_value=True) + await manager.stop_all() + + orphaned_owner.stop.assert_awaited_once() + assert manager._closing_sessions == set() + + +@pytest.mark.asyncio +async def test_stop_all_attempts_every_session_and_retains_only_failures() -> None: + manager = _manager() + returned_false = _mock_session(False) + raised_error = _mock_session(RuntimeError("secret failure detail")) + stopped = _mock_session(True) + manager._sessions.update( + { + "real_error": raised_error, + "real_false": returned_false, + "real_stopped": stopped, + } + ) + manager._temp_to_real.update( + { + "temp_error": "real_error", + "temp_false": "real_false", + "temp_stopped": "real_stopped", + } + ) + manager._real_to_temp.update( + { + "real_error": "temp_error", + "real_false": "temp_false", + "real_stopped": "temp_stopped", + } + ) + + with pytest.raises(RuntimeError) as exc_info: + await manager.stop_all() + + assert str(exc_info.value) == "Managed Claude session shutdown failures: 2." + returned_false.stop.assert_awaited_once() + raised_error.stop.assert_awaited_once() + stopped.stop.assert_awaited_once() + assert manager._sessions == { + "real_error": raised_error, + "real_false": returned_false, + } + assert manager._pending_sessions == {} + assert manager._temp_to_real == { + "temp_error": "real_error", + "temp_false": "real_false", + } + assert manager._real_to_temp == { + "real_error": "temp_error", + "real_false": "temp_false", + } + with pytest.raises(RuntimeError, match="closing"): + await manager.get_or_create_session("temp_false") + with pytest.raises(RuntimeError, match="closing"): + await manager.get_or_create_session("temp_error") + + returned_false.stop = AsyncMock(return_value=True) + raised_error.stop = AsyncMock(return_value=True) + await manager.stop_all() + + assert manager._sessions == {} + assert manager._pending_sessions == {} + assert manager._temp_to_real == {} + assert manager._real_to_temp == {} diff --git a/tests/cli/test_process_registry.py b/tests/cli/test_process_registry.py new file mode 100644 index 0000000..cfe6612 --- /dev/null +++ b/tests/cli/test_process_registry.py @@ -0,0 +1,92 @@ +import os +import subprocess +from unittest.mock import patch + + +def test_process_registry_register_pid_zero_noop(): + """register_pid(0) is a no-op (early return).""" + from free_claude_code.cli import process_registry as pr + + before = len(pr._pids) + pr.register_pid(0) + assert len(pr._pids) == before + + +def test_process_registry_unregister_pid_zero_noop(): + """unregister_pid(0) is a no-op.""" + from free_claude_code.cli import process_registry as pr + + pr.register_pid(99999) + pr.unregister_pid(0) + assert 99999 in pr._pids + pr.unregister_pid(99999) + + +def test_process_registry_ensure_atexit_idempotent(): + """Second call to ensure_atexit_registered is idempotent.""" + from free_claude_code.cli import process_registry as pr + + pr.ensure_atexit_registered() + pr.ensure_atexit_registered() + # Should not raise; atexit handler registered once + + +def test_process_registry_kill_all_exception_logged_no_raise(monkeypatch): + """Exception in os.kill/taskkill is logged but does not raise.""" + from free_claude_code.cli import process_registry as pr + + monkeypatch.setattr(pr, "_pids", {99999}) + monkeypatch.setattr(os, "name", "posix", raising=False) + + def _kill_raises(pid, sig): + raise ProcessLookupError("no such process") + + with patch("os.kill", _kill_raises): + pr.kill_all_best_effort() + # Should not raise + + +def test_process_registry_register_unregister_does_not_crash(): + from free_claude_code.cli import process_registry as pr + + pr.register_pid(12345) + pr.unregister_pid(12345) + + +def test_process_registry_kill_all_best_effort_empty_is_noop(): + from free_claude_code.cli import process_registry as pr + + # Ensure no exception on empty set + pr.kill_all_best_effort() + + +def test_process_registry_kill_all_best_effort_windows_noop_when_taskkill_missing( + monkeypatch, +): + from free_claude_code.cli import process_registry as pr + + # Simulate windows path in a stable way. + monkeypatch.setattr(pr, "_pids", {12345}) + monkeypatch.setattr(os, "name", "nt", raising=False) + + # If taskkill isn't callable, we still should not crash. + import subprocess + + def _boom(*args, **kwargs): + raise FileNotFoundError("taskkill missing") + + monkeypatch.setattr(subprocess, "run", _boom) + pr.kill_all_best_effort() + + +def test_process_registry_kill_pid_tree_windows_uses_taskkill(monkeypatch): + from free_claude_code.cli import process_registry as pr + + calls = [] + monkeypatch.setattr(os, "name", "nt", raising=False) + monkeypatch.setattr(subprocess, "run", lambda *a, **kw: calls.append((a, kw))) + + pr.kill_pid_tree_best_effort(12345) + + assert calls + assert calls[0][0][0] == ["taskkill", "/PID", "12345", "/T", "/F"] diff --git a/tests/config/test_config.py b/tests/config/test_config.py new file mode 100644 index 0000000..b848b5e --- /dev/null +++ b/tests/config/test_config.py @@ -0,0 +1,1041 @@ +"""Tests for config/settings.py and config/nim.py""" + +from typing import Any, cast + +import pytest +from pydantic import ValidationError + +from free_claude_code.config.constants import ( + ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + HTTP_CONNECT_TIMEOUT_DEFAULT, +) +from free_claude_code.config.env_files import ( + ANTHROPIC_AUTH_TOKEN_ENV, + process_env_key_is_effective, +) +from free_claude_code.config.model_refs import ( + configured_chat_model_refs, + parse_model_name, + parse_provider_type, +) +from free_claude_code.config.nim import NimSettings +from free_claude_code.config.paths import messaging_state_dir_path + + +class TestSettings: + """Test Settings configuration.""" + + def test_settings_loads(self): + """Ensure Settings can be instantiated.""" + from free_claude_code.config.settings import Settings + + settings = Settings() + assert settings is not None + + def test_default_values(self, monkeypatch): + """Test default values are set and have correct types.""" + from free_claude_code.config.settings import Settings + + monkeypatch.delenv("CLAUDE_WORKSPACE", raising=False) + monkeypatch.delenv("MODEL", raising=False) + monkeypatch.delenv("HTTP_READ_TIMEOUT", raising=False) + monkeypatch.delenv("HTTP_CONNECT_TIMEOUT", raising=False) + monkeypatch.setitem(Settings.model_config, "env_file", ()) + settings = Settings() + assert settings.model == "nvidia_nim/nvidia/nemotron-3-super-120b-a12b" + assert isinstance(settings.provider_rate_limit, int) + assert isinstance(settings.provider_rate_window, int) + assert isinstance(settings.nim.temperature, float) + assert isinstance(settings.fast_prefix_detection, bool) + assert isinstance(settings.enable_model_thinking, bool) + assert settings.http_read_timeout == 120.0 + assert settings.http_connect_timeout == HTTP_CONNECT_TIMEOUT_DEFAULT + assert settings.enable_web_server_tools is False + assert settings.log_raw_api_payloads is False + assert settings.log_raw_sse_events is False + assert settings.debug_platform_edits is False + assert settings.debug_subagent_stack is False + + def test_default_claude_workspace_uses_fcc_home(self, monkeypatch, tmp_path): + """Unset CLAUDE_WORKSPACE stores agent data under the fixed path helper.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("CLAUDE_WORKSPACE", raising=False) + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert messaging_state_dir_path() == tmp_path / ".fcc" / "agent_workspace" + assert not hasattr(settings, "claude_workspace") + + def test_server_log_path_uses_fcc_home(self, monkeypatch, tmp_path): + """The server log location is fixed under ~/.fcc.""" + from free_claude_code.config.paths import server_log_path + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + + assert server_log_path() == tmp_path / ".fcc" / "logs" / "server.log" + + def test_removed_log_file_env_is_ignored(self, monkeypatch): + """Legacy LOG_FILE values do not affect Settings or block startup.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("LOG_FILE", "custom/server.log") + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert not hasattr(settings, "log_file") + + def test_stale_zai_base_url_env_is_ignored(self, monkeypatch): + """Cloud Z.ai endpoint is fixed in provider metadata, not settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("ZAI_BASE_URL", "https://custom.zai.invalid/v1") + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert not hasattr(settings, "zai_base_url") + + def test_blank_claude_workspace_uses_fcc_home(self, monkeypatch, tmp_path): + """An explicit blank env value does not affect the fixed workspace helper.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setenv("CLAUDE_WORKSPACE", "") + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert messaging_state_dir_path() == tmp_path / ".fcc" / "agent_workspace" + assert not hasattr(settings, "claude_workspace") + + def test_explicit_claude_workspace_is_ignored(self, monkeypatch, tmp_path): + """Custom CLAUDE_WORKSPACE values do not override the fixed workspace helper.""" + from free_claude_code.config.settings import Settings + + workspace = tmp_path / "custom-workspace" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setenv("CLAUDE_WORKSPACE", str(workspace)) + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert messaging_state_dir_path() == tmp_path / ".fcc" / "agent_workspace" + assert not hasattr(settings, "claude_workspace") + + def test_explicit_claude_cli_bin_is_ignored(self, monkeypatch): + """Custom CLAUDE_CLI_BIN values do not become Settings fields.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("CLAUDE_CLI_BIN", "claude-custom") + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert not hasattr(settings, "claude_cli_bin") + assert not hasattr(settings, "codex_cli_bin") + + def test_direct_claude_runtime_overrides_are_ignored(self, monkeypatch, tmp_path): + """Constructor extras cannot add fixed Claude runtime settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings( + **cast( + Any, + { + "claude_workspace": str(tmp_path / "custom-workspace"), + "claude_cli_bin": "claude-custom", + }, + ) + ) + + assert messaging_state_dir_path() == tmp_path / ".fcc" / "agent_workspace" + assert not hasattr(settings, "claude_workspace") + assert not hasattr(settings, "claude_cli_bin") + + def test_get_settings_cached(self): + """Test get_settings returns cached instance.""" + from free_claude_code.config.settings import get_settings + + s1 = get_settings() + s2 = get_settings() + assert s1 is s2 # Same object (cached) + + def test_empty_string_to_none_for_optional_int(self): + """Test that empty string converts to None for optional int fields.""" + from free_claude_code.config.settings import Settings + + # Settings should handle NVIDIA_NIM_SEED="" gracefully + settings = Settings() + assert settings.nim.seed is None or isinstance(settings.nim.seed, int) + + def test_model_setting(self): + """Test model setting exists and is a string.""" + from free_claude_code.config.settings import Settings + + settings = Settings() + assert isinstance(settings.model, str) + assert len(settings.model) > 0 + + def test_base_url_constant(self): + """Test NVIDIA_NIM_DEFAULT_BASE is a constant.""" + from free_claude_code.config.provider_catalog import NVIDIA_NIM_DEFAULT_BASE + + assert NVIDIA_NIM_DEFAULT_BASE == "https://integrate.api.nvidia.com/v1" + + def test_lm_studio_base_url_from_env(self, monkeypatch): + """LM_STUDIO_BASE_URL env var is loaded into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("LM_STUDIO_BASE_URL", "http://custom:5678/v1") + settings = Settings() + assert settings.lm_studio_base_url == "http://custom:5678/v1" + + def test_ollama_base_url_defaults_to_root(self, monkeypatch): + """OLLAMA_BASE_URL keeps the customer-facing Ollama root default.""" + from free_claude_code.config.settings import Settings + + monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) + monkeypatch.setitem(Settings.model_config, "env_file", ()) + settings = Settings() + assert settings.ollama_base_url == "http://localhost:11434" + + def test_ollama_base_url_accepts_v1_suffix(self, monkeypatch): + """The adapter accepts either the root URL or the explicit OpenAI path.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost:11434/v1") + assert Settings().ollama_base_url == "http://localhost:11434/v1" + + def test_provider_rate_limit_from_env(self, monkeypatch): + """PROVIDER_RATE_LIMIT env var is loaded into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("PROVIDER_RATE_LIMIT", "20") + settings = Settings() + assert settings.provider_rate_limit == 20 + + def test_provider_rate_window_from_env(self, monkeypatch): + """PROVIDER_RATE_WINDOW env var is loaded into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("PROVIDER_RATE_WINDOW", "30") + settings = Settings() + assert settings.provider_rate_window == 30 + + def test_http_read_timeout_from_env(self, monkeypatch): + """HTTP_READ_TIMEOUT env var is loaded into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("HTTP_READ_TIMEOUT", "600") + settings = Settings() + assert settings.http_read_timeout == 600.0 + + def test_http_write_timeout_from_env(self, monkeypatch): + """HTTP_WRITE_TIMEOUT env var is loaded into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("HTTP_WRITE_TIMEOUT", "20") + settings = Settings() + assert settings.http_write_timeout == 20.0 + + def test_http_connect_timeout_from_env(self, monkeypatch): + """HTTP_CONNECT_TIMEOUT env var is loaded into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("HTTP_CONNECT_TIMEOUT", "5") + settings = Settings() + assert settings.http_connect_timeout == 5.0 + + def test_http_connect_timeout_default_matches_shared_constant( + self, monkeypatch + ) -> None: + """Default must match config.constants (and README / .env.example).""" + from free_claude_code.config.settings import Settings + + monkeypatch.delenv("HTTP_CONNECT_TIMEOUT", raising=False) + monkeypatch.setitem(Settings.model_config, "env_file", ()) + settings = Settings() + assert settings.http_connect_timeout == HTTP_CONNECT_TIMEOUT_DEFAULT + assert HTTP_CONNECT_TIMEOUT_DEFAULT == 10.0 + + def test_enable_model_thinking_from_env(self, monkeypatch): + """ENABLE_MODEL_THINKING env var is loaded into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("ENABLE_MODEL_THINKING", "false") + settings = Settings() + assert settings.enable_model_thinking is False + + def test_wafer_api_key_from_env(self, monkeypatch): + """WAFER_API_KEY env var is loaded into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("WAFER_API_KEY", "wafer-key") + settings = Settings() + assert settings.wafer_api_key == "wafer-key" + + def test_minimax_settings_from_env(self, monkeypatch): + """MiniMax key and proxy env vars load into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("MINIMAX_API_KEY", "minimax-key") + monkeypatch.setenv("MINIMAX_PROXY", "http://proxy.test:8080") + settings = Settings() + assert settings.minimax_api_key == "minimax-key" + assert settings.minimax_proxy == "http://proxy.test:8080" + + def test_cloudflare_settings_from_env(self, monkeypatch): + """Cloudflare token, account, and proxy env vars load into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("CLOUDFLARE_API_TOKEN", "cf-token") + monkeypatch.setenv("CLOUDFLARE_ACCOUNT_ID", "cf-account") + monkeypatch.setenv("CLOUDFLARE_PROXY", "http://proxy.test:8080") + settings = Settings() + assert settings.cloudflare_api_token == "cf-token" + assert settings.cloudflare_account_id == "cf-account" + assert settings.cloudflare_proxy == "http://proxy.test:8080" + + def test_vercel_settings_from_env(self, monkeypatch): + """Vercel AI Gateway key and proxy env vars load into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("AI_GATEWAY_API_KEY", "vercel-key") + monkeypatch.setenv("VERCEL_AI_GATEWAY_PROXY", "http://proxy.test:8080") + settings = Settings() + assert settings.vercel_ai_gateway_api_key == "vercel-key" + assert settings.vercel_ai_gateway_proxy == "http://proxy.test:8080" + + def test_huggingface_settings_from_env(self, monkeypatch): + """Hugging Face key and proxy env vars load into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("HUGGINGFACE_API_KEY", "hf-key") + monkeypatch.setenv("HUGGINGFACE_PROXY", "http://proxy.test:8080") + settings = Settings() + assert settings.huggingface_api_key == "hf-key" + assert settings.huggingface_proxy == "http://proxy.test:8080" + assert not hasattr(settings, "hf_token") + + def test_cohere_settings_from_env(self, monkeypatch): + """Cohere key and proxy env vars load into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("COHERE_API_KEY", "cohere-key") + monkeypatch.setenv("COHERE_PROXY", "http://proxy.test:8080") + settings = Settings() + assert settings.cohere_api_key == "cohere-key" + assert settings.cohere_proxy == "http://proxy.test:8080" + + def test_github_models_settings_from_env(self, monkeypatch): + """GitHub Models token and proxy env vars load into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("GITHUB_MODELS_TOKEN", "github-token") + monkeypatch.setenv("GITHUB_MODELS_PROXY", "http://proxy.test:8080") + settings = Settings() + assert settings.github_models_token == "github-token" + assert settings.github_models_proxy == "http://proxy.test:8080" + + def test_sambanova_settings_from_env(self, monkeypatch): + """SambaNova key and proxy env vars load into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("SAMBANOVA_API_KEY", "sambanova-key") + monkeypatch.setenv("SAMBANOVA_PROXY", "http://proxy.test:8080") + settings = Settings() + assert settings.sambanova_api_key == "sambanova-key" + assert settings.sambanova_proxy == "http://proxy.test:8080" + + def test_legacy_hf_token_env_is_ignored(self, monkeypatch): + """HF_TOKEN is migrated by startup config migration, not read by Settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("HF_TOKEN", "legacy-token") + monkeypatch.delenv("HUGGINGFACE_API_KEY", raising=False) + settings = Settings() + assert settings.huggingface_api_key == "" + assert not hasattr(settings, "hf_token") + + def test_per_model_thinking_from_env(self, monkeypatch): + """Per-model thinking env vars are loaded into settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("ENABLE_OPUS_THINKING", "true") + monkeypatch.setenv("ENABLE_SONNET_THINKING", "false") + monkeypatch.setenv("ENABLE_HAIKU_THINKING", "false") + settings = Settings() + assert settings.enable_opus_thinking is True + assert settings.enable_sonnet_thinking is False + assert settings.enable_haiku_thinking is False + + def test_empty_per_model_thinking_inherits_model_default(self, monkeypatch): + """Blank per-model thinking env vars are treated as unset.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("ENABLE_MODEL_THINKING", "false") + monkeypatch.setenv("ENABLE_OPUS_THINKING", "") + settings = Settings() + assert settings.enable_opus_thinking is None + assert ( + ModelRouter(settings).resolve("claude-opus-4-20250514").thinking_enabled + is False + ) + + def test_resolve_thinking_uses_model_tiers(self, monkeypatch): + """ModelRouter applies tier thinking override then fallback.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("ENABLE_MODEL_THINKING", "false") + monkeypatch.setenv("ENABLE_OPUS_THINKING", "true") + monkeypatch.setenv("ENABLE_HAIKU_THINKING", "false") + settings = Settings() + router = ModelRouter(settings) + assert router.resolve("claude-opus-4-20250514").thinking_enabled is True + assert router.resolve("claude-sonnet-4-20250514").thinking_enabled is False + assert router.resolve("claude-haiku-4-20250514").thinking_enabled is False + assert router.resolve("unknown-model").thinking_enabled is False + + def test_anthropic_auth_token_from_env_without_dotenv_key(self, monkeypatch): + """ANTHROPIC_AUTH_TOKEN env var is loaded when dotenv does not define it.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "process-token") + monkeypatch.setitem(Settings.model_config, "env_file", ()) + settings = Settings() + assert settings.anthropic_auth_token == "process-token" + assert ( + process_env_key_is_effective( + Settings.model_config, ANTHROPIC_AUTH_TOKEN_ENV + ) + is True + ) + + def test_empty_dotenv_anthropic_auth_token_overrides_process_env( + self, monkeypatch, tmp_path + ): + """An explicit empty .env token disables auth despite stale shell tokens.""" + from free_claude_code.config.settings import Settings + + env_file = tmp_path / ".env" + env_file.write_text("ANTHROPIC_AUTH_TOKEN=\n", encoding="utf-8") + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "stale-client-token") + monkeypatch.setitem(Settings.model_config, "env_file", (env_file,)) + + settings = Settings() + assert settings.anthropic_auth_token == "" + assert ( + process_env_key_is_effective( + Settings.model_config, ANTHROPIC_AUTH_TOKEN_ENV + ) + is False + ) + + def test_dotenv_anthropic_auth_token_overrides_process_env( + self, monkeypatch, tmp_path + ): + """A configured .env token is the server token even with a stale shell token.""" + from free_claude_code.config.settings import Settings + + env_file = tmp_path / ".env" + env_file.write_text( + 'ANTHROPIC_AUTH_TOKEN="server-token"\n', + encoding="utf-8", + ) + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "stale-client-token") + monkeypatch.setitem(Settings.model_config, "env_file", (env_file,)) + + settings = Settings() + assert settings.anthropic_auth_token == "server-token" + assert ( + process_env_key_is_effective( + Settings.model_config, ANTHROPIC_AUTH_TOKEN_ENV + ) + is False + ) + + @pytest.mark.parametrize("removed_key", ["NIM_ENABLE_THINKING", "ENABLE_THINKING"]) + def test_removed_thinking_env_keys_are_ignored(self, monkeypatch, removed_key): + """Stale thinking env keys do not block startup or affect settings.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv(removed_key, "false") + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert settings.enable_model_thinking is True + + @pytest.mark.parametrize("removed_key", ["NIM_ENABLE_THINKING", "ENABLE_THINKING"]) + @pytest.mark.parametrize("value", ["false", ""]) + def test_removed_thinking_dotenv_keys_are_ignored( + self, monkeypatch, tmp_path, removed_key, value + ): + """Stale thinking dotenv keys do not block startup or affect settings.""" + from free_claude_code.config.settings import Settings + + env_file = tmp_path / ".env" + env_file.write_text(f"{removed_key}={value}\n", encoding="utf-8") + monkeypatch.delenv(removed_key, raising=False) + monkeypatch.setitem(Settings.model_config, "env_file", (env_file,)) + + settings = Settings() + + assert settings.enable_model_thinking is True + + +# --- NimSettings Validation Tests --- +class TestNimSettingsValidBounds: + """Test that valid values within bounds are accepted.""" + + @pytest.mark.parametrize("top_k", [-1, 0, 1, 100]) + def test_top_k_valid(self, top_k): + """top_k >= -1 should be accepted.""" + s = NimSettings(top_k=top_k) + assert s.top_k == top_k + + @pytest.mark.parametrize("temp", [0.0, 0.5, 1.0, 2.0]) + def test_temperature_valid(self, temp): + s = NimSettings(temperature=temp) + assert s.temperature == temp + + @pytest.mark.parametrize("top_p", [0.0, 0.5, 1.0]) + def test_top_p_valid(self, top_p): + s = NimSettings(top_p=top_p) + assert s.top_p == top_p + + def test_max_tokens_valid(self): + s = NimSettings(max_tokens=1) + assert s.max_tokens == 1 + + def test_min_tokens_valid(self): + s = NimSettings(min_tokens=0) + assert s.min_tokens == 0 + + @pytest.mark.parametrize("penalty", [-2.0, 0.0, 2.0]) + def test_presence_penalty_valid(self, penalty): + s = NimSettings(presence_penalty=penalty) + assert s.presence_penalty == penalty + + @pytest.mark.parametrize("penalty", [-2.0, 0.0, 2.0]) + def test_frequency_penalty_valid(self, penalty): + s = NimSettings(frequency_penalty=penalty) + assert s.frequency_penalty == penalty + + @pytest.mark.parametrize("min_p", [0.0, 0.5, 1.0]) + def test_min_p_valid(self, min_p): + s = NimSettings(min_p=min_p) + assert s.min_p == min_p + + +class TestNimSettingsInvalidBounds: + """Test that out-of-range values raise ValidationError.""" + + @pytest.mark.parametrize("top_k", [-2, -100]) + def test_top_k_below_lower_bound(self, top_k): + with pytest.raises((ValidationError, ValueError)): + NimSettings(top_k=top_k) + + def test_temperature_negative(self): + with pytest.raises(ValidationError): + NimSettings(temperature=-0.1) + + @pytest.mark.parametrize("top_p", [-0.1, 1.1]) + def test_top_p_out_of_range(self, top_p): + with pytest.raises(ValidationError): + NimSettings(top_p=top_p) + + @pytest.mark.parametrize("penalty", [-2.1, 2.1]) + def test_presence_penalty_out_of_range(self, penalty): + with pytest.raises(ValidationError): + NimSettings(presence_penalty=penalty) + + @pytest.mark.parametrize("penalty", [-2.1, 2.1]) + def test_frequency_penalty_out_of_range(self, penalty): + with pytest.raises(ValidationError): + NimSettings(frequency_penalty=penalty) + + @pytest.mark.parametrize("min_p", [-0.1, 1.1]) + def test_min_p_out_of_range(self, min_p): + with pytest.raises(ValidationError): + NimSettings(min_p=min_p) + + @pytest.mark.parametrize("max_tokens", [0, -1]) + def test_max_tokens_too_low(self, max_tokens): + with pytest.raises(ValidationError): + NimSettings(max_tokens=max_tokens) + + def test_min_tokens_negative(self): + with pytest.raises(ValidationError): + NimSettings(min_tokens=-1) + + +class TestNimSettingsValidators: + """Test custom field validators in NimSettings.""" + + def test_default_max_tokens_matches_shared_constant(self): + assert NimSettings().max_tokens == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + @pytest.mark.parametrize( + "seed_val,expected", + [("", None), (None, None), ("42", 42), (42, 42)], + ids=["empty_str", "none", "str_42", "int_42"], + ) + def test_parse_optional_int(self, seed_val, expected): + s = NimSettings(seed=seed_val) + assert s.seed == expected + + @pytest.mark.parametrize( + "stop_val,expected", + [("", None), ("STOP", "STOP"), (None, None)], + ids=["empty_str", "valid", "none"], + ) + def test_parse_optional_str_stop(self, stop_val, expected): + s = NimSettings(stop=stop_val) + assert s.stop == expected + + @pytest.mark.parametrize( + "chat_template_val,expected", + [("", None), ("template", "template")], + ids=["empty_str", "valid"], + ) + def test_parse_optional_str_chat_template(self, chat_template_val, expected): + s = NimSettings(chat_template=chat_template_val) + assert s.chat_template == expected + + def test_extra_forbid_rejects_unknown_field(self): + """NimSettings with extra='forbid' rejects unknown fields.""" + from typing import Any, cast + + with pytest.raises(ValidationError): + NimSettings(**cast(Any, {"unknown_field": "value"})) + + def test_enable_thinking_field_removed(self): + """NimSettings no longer accepts the removed thinking toggle.""" + from typing import Any, cast + + with pytest.raises(ValidationError): + NimSettings(**cast(Any, {"enable_thinking": True})) + + +class TestSettingsOptionalStr: + """Test Settings parse_optional_str validator.""" + + def test_empty_telegram_token_to_none(self, monkeypatch): + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "") + s = Settings() + assert s.telegram_bot_token is None + + def test_valid_telegram_token_preserved(self, monkeypatch): + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "abc123") + s = Settings() + assert s.telegram_bot_token == "abc123" + + def test_empty_allowed_user_id_to_none(self, monkeypatch): + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("ALLOWED_TELEGRAM_USER_ID", "") + s = Settings() + assert s.allowed_telegram_user_id is None + + def test_discord_bot_token_from_env(self, monkeypatch): + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("DISCORD_BOT_TOKEN", "discord_token_123") + s = Settings() + assert s.discord_bot_token == "discord_token_123" + + def test_empty_discord_bot_token_to_none(self, monkeypatch): + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("DISCORD_BOT_TOKEN", "") + s = Settings() + assert s.discord_bot_token is None + + def test_allowed_discord_channels_from_env(self, monkeypatch): + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("ALLOWED_DISCORD_CHANNELS", "111,222,333") + s = Settings() + assert s.allowed_discord_channels == "111,222,333" + + def test_messaging_platform_from_env(self, monkeypatch): + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("MESSAGING_PLATFORM", "discord") + s = Settings() + assert s.messaging_platform == "discord" + + def test_whisper_device_auto_rejected(self, monkeypatch): + """WHISPER_DEVICE=auto raises ValidationError (auto removed).""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("WHISPER_DEVICE", "auto") + with pytest.raises(ValidationError, match="whisper_device"): + Settings() + + @pytest.mark.parametrize("device", ["cpu", "cuda"]) + def test_whisper_device_valid(self, monkeypatch, device): + """Valid whisper_device values are accepted.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("WHISPER_DEVICE", device) + s = Settings() + assert s.whisper_device == device + + +class TestPerModelMapping: + """Test per-model settings and model-ref helpers.""" + + def test_model_fields_default_none(self): + """Per-model fields default to None.""" + from free_claude_code.config.settings import Settings + + s = Settings() + assert s.model_opus is None + assert s.model_sonnet is None + assert s.model_haiku is None + + def test_model_opus_from_env(self, monkeypatch): + """MODEL_OPUS env var is loaded.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("MODEL_OPUS", "open_router/deepseek/deepseek-r1") + s = Settings() + assert s.model_opus == "open_router/deepseek/deepseek-r1" + + @pytest.mark.parametrize("env_var", ["MODEL_OPUS", "MODEL_SONNET", "MODEL_HAIKU"]) + def test_empty_model_override_env_is_unset(self, monkeypatch, env_var): + """Empty per-model override env vars are treated as unset.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + monkeypatch.setenv(env_var, "") + s = Settings() + assert getattr(s, env_var.lower()) is None + assert ( + ModelRouter(s) + .resolve(f"claude-{env_var.removeprefix('MODEL_').lower()}-4") + .provider_model_ref + == s.model + ) + + @pytest.mark.parametrize( + "env_vars,expected_model,expected_haiku", + [ + ( + {"MODEL": "nvidia_nim/meta/llama3-70b-instruct"}, + "nvidia_nim/meta/llama3-70b-instruct", + None, + ), + ( + { + "MODEL": "open_router/anthropic/claude-3-opus", + "MODEL_HAIKU": "open_router/anthropic/claude-3-haiku", + }, + "open_router/anthropic/claude-3-opus", + "open_router/anthropic/claude-3-haiku", + ), + ({"MODEL": "deepseek/deepseek-chat"}, "deepseek/deepseek-chat", None), + ({"MODEL": "wafer/DeepSeek-V4-Pro"}, "wafer/DeepSeek-V4-Pro", None), + ( + {"MODEL": "cloudflare/@cf/moonshotai/kimi-k2.6"}, + "cloudflare/@cf/moonshotai/kimi-k2.6", + None, + ), + ( + {"MODEL": "github_models/openai/gpt-4.1"}, + "github_models/openai/gpt-4.1", + None, + ), + ( + {"MODEL": "sambanova/Meta-Llama-3.3-70B-Instruct"}, + "sambanova/Meta-Llama-3.3-70B-Instruct", + None, + ), + ({"MODEL": "lmstudio/qwen2.5-7b"}, "lmstudio/qwen2.5-7b", None), + ({"MODEL": "llamacpp/local-model"}, "llamacpp/local-model", None), + ({"MODEL": "ollama/llama3.1"}, "ollama/llama3.1", None), + ], + ) + def test_settings_models_from_env( + self, env_vars, expected_model, expected_haiku, monkeypatch + ): + """Test environment variables override model defaults.""" + from free_claude_code.config.settings import Settings + + for k, v in env_vars.items(): + monkeypatch.setenv(k, v) + + s = Settings() + assert s.model == expected_model + assert s.model_haiku == expected_haiku + + def test_model_sonnet_from_env(self, monkeypatch): + """MODEL_SONNET env var is loaded.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("MODEL_SONNET", "nvidia_nim/meta/llama-3.3-70b-instruct") + s = Settings() + assert s.model_sonnet == "nvidia_nim/meta/llama-3.3-70b-instruct" + + def test_model_haiku_from_env(self, monkeypatch): + """MODEL_HAIKU env var is loaded.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("MODEL_HAIKU", "lmstudio/qwen2.5-7b") + s = Settings() + assert s.model_haiku == "lmstudio/qwen2.5-7b" + + def test_model_opus_invalid_provider_raises(self, monkeypatch): + """MODEL_OPUS with invalid provider prefix raises ValidationError.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("MODEL_OPUS", "bad_provider/some-model") + with pytest.raises(ValidationError, match="Invalid provider"): + Settings() + + def test_model_opus_no_slash_raises(self, monkeypatch): + """MODEL_OPUS without provider prefix raises ValidationError.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("MODEL_OPUS", "noprefix") + with pytest.raises(ValidationError, match="provider type"): + Settings() + + def test_model_haiku_invalid_provider_raises(self, monkeypatch): + """MODEL_HAIKU with invalid provider prefix raises ValidationError.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("MODEL_HAIKU", "invalid/model") + with pytest.raises(ValidationError, match="Invalid provider"): + Settings() + + def test_resolve_model_opus_override(self): + """ModelRouter returns model_opus for opus model names.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + s = Settings() + s.model_opus = "open_router/deepseek/deepseek-r1" + router = ModelRouter(s) + assert ( + router.resolve("claude-opus-4-20250514").provider_model_ref + == "open_router/deepseek/deepseek-r1" + ) + assert ( + router.resolve("claude-3-opus").provider_model_ref + == "open_router/deepseek/deepseek-r1" + ) + assert ( + router.resolve("claude-3-opus-20240229").provider_model_ref + == "open_router/deepseek/deepseek-r1" + ) + + def test_resolve_model_sonnet_override(self): + """ModelRouter returns model_sonnet for sonnet model names.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + s = Settings() + s.model_sonnet = "nvidia_nim/meta/llama-3.3-70b-instruct" + router = ModelRouter(s) + assert ( + router.resolve("claude-sonnet-4-20250514").provider_model_ref + == "nvidia_nim/meta/llama-3.3-70b-instruct" + ) + assert ( + router.resolve("claude-3-5-sonnet-20241022").provider_model_ref + == "nvidia_nim/meta/llama-3.3-70b-instruct" + ) + + def test_resolve_model_haiku_override(self): + """ModelRouter returns model_haiku for haiku model names.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + s = Settings() + s.model_haiku = "lmstudio/qwen2.5-7b" + router = ModelRouter(s) + assert ( + router.resolve("claude-3-haiku-20240307").provider_model_ref + == "lmstudio/qwen2.5-7b" + ) + assert ( + router.resolve("claude-3-5-haiku-20241022").provider_model_ref + == "lmstudio/qwen2.5-7b" + ) + assert ( + router.resolve("claude-haiku-4-20250514").provider_model_ref + == "lmstudio/qwen2.5-7b" + ) + + def test_resolve_model_fallback_when_override_not_set(self): + """ModelRouter falls back to MODEL when model override is None.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + s = Settings() + s.model = "nvidia_nim/fallback-model" + router = ModelRouter(s) + assert ( + router.resolve("claude-opus-4-20250514").provider_model_ref + == "nvidia_nim/fallback-model" + ) + assert ( + router.resolve("claude-sonnet-4-20250514").provider_model_ref + == "nvidia_nim/fallback-model" + ) + assert ( + router.resolve("claude-3-haiku-20240307").provider_model_ref + == "nvidia_nim/fallback-model" + ) + + def test_resolve_model_unknown_model_falls_back(self): + """ModelRouter falls back to MODEL for unrecognized model names.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + s = Settings() + s.model = "nvidia_nim/fallback-model" + s.model_opus = "open_router/opus-model" + router = ModelRouter(s) + assert router.resolve("claude-2.1").provider_model_ref == ( + "nvidia_nim/fallback-model" + ) + assert router.resolve("some-unknown-model").provider_model_ref == ( + "nvidia_nim/fallback-model" + ) + + def test_resolve_model_case_insensitive(self): + """Model classification is case-insensitive.""" + from free_claude_code.application.routing import ModelRouter + from free_claude_code.config.settings import Settings + + s = Settings() + s.model_opus = "open_router/opus-model" + assert ( + ModelRouter(s).resolve("Claude-OPUS-4").provider_model_ref + == "open_router/opus-model" + ) + + def test_parse_provider_type(self): + """parse_provider_type extracts provider from model string.""" + + assert parse_provider_type("nvidia_nim/meta/llama") == "nvidia_nim" + assert parse_provider_type("open_router/deepseek/r1") == "open_router" + assert parse_provider_type("mistral/devstral-small-latest") == "mistral" + assert ( + parse_provider_type("mistral_codestral/codestral-latest") + == "mistral_codestral" + ) + assert parse_provider_type("deepseek/deepseek-chat") == "deepseek" + assert parse_provider_type("lmstudio/qwen") == "lmstudio" + assert parse_provider_type("llamacpp/model") == "llamacpp" + assert parse_provider_type("ollama/llama3.1") == "ollama" + assert parse_provider_type("wafer/DeepSeek-V4-Pro") == "wafer" + assert parse_provider_type("minimax/MiniMax-M3") == "minimax" + assert ( + parse_provider_type("cloudflare/@cf/moonshotai/kimi-k2.6") == "cloudflare" + ) + assert parse_provider_type("vercel/openai/gpt-5.5") == "vercel" + assert ( + parse_provider_type("huggingface/openai/gpt-oss-120b:fastest") + == "huggingface" + ) + assert parse_provider_type("cohere/command-a-plus-05-2026") == "cohere" + assert parse_provider_type("github_models/openai/gpt-4.1") == ("github_models") + assert parse_provider_type("gemini/models/gemini-3.1-flash-lite") == "gemini" + assert parse_provider_type("groq/llama-3.3-70b-versatile") == "groq" + assert ( + parse_provider_type("sambanova/Meta-Llama-3.3-70B-Instruct") == "sambanova" + ) + assert parse_provider_type("cerebras/llama3.1-8b") == "cerebras" + + def test_parse_model_name(self): + """parse_model_name extracts model name from model string.""" + + assert parse_model_name("nvidia_nim/meta/llama") == "meta/llama" + assert parse_model_name("mistral/devstral-small-latest") == ( + "devstral-small-latest" + ) + assert ( + parse_model_name("mistral_codestral/codestral-latest") == "codestral-latest" + ) + assert parse_model_name("deepseek/deepseek-chat") == "deepseek-chat" + assert parse_model_name("lmstudio/qwen") == "qwen" + assert parse_model_name("llamacpp/model") == "model" + assert parse_model_name("ollama/llama3.1") == "llama3.1" + assert parse_model_name("wafer/DeepSeek-V4-Pro") == "DeepSeek-V4-Pro" + assert parse_model_name("minimax/MiniMax-M3") == "MiniMax-M3" + assert ( + parse_model_name("cloudflare/@cf/moonshotai/kimi-k2.6") + == "@cf/moonshotai/kimi-k2.6" + ) + assert parse_model_name("vercel/openai/gpt-5.5") == "openai/gpt-5.5" + assert ( + parse_model_name("huggingface/openai/gpt-oss-120b:fastest") + == "openai/gpt-oss-120b:fastest" + ) + assert parse_model_name("cohere/command-a-plus-05-2026") == ( + "command-a-plus-05-2026" + ) + assert parse_model_name("github_models/openai/gpt-4.1") == "openai/gpt-4.1" + assert ( + parse_model_name("gemini/models/gemini-3.1-flash-lite") + == "models/gemini-3.1-flash-lite" + ) + assert ( + parse_model_name("groq/llama-3.3-70b-versatile") + == "llama-3.3-70b-versatile" + ) + assert ( + parse_model_name("sambanova/Meta-Llama-3.3-70B-Instruct") + == "Meta-Llama-3.3-70B-Instruct" + ) + assert parse_model_name("cerebras/llama3.1-8b") == "llama3.1-8b" + + def test_configured_chat_model_refs_collects_unique_models_with_sources( + self, monkeypatch + ): + """Startup validation model collection is limited to configured chat refs.""" + from free_claude_code.config.settings import Settings + + monkeypatch.setenv("FCC_SMOKE_MODEL_NVIDIA_NIM", "nvidia_nim/smoke") + monkeypatch.setenv("WHISPER_MODEL", "openai/whisper-large-v3") + s = Settings() + s.model = "nvidia_nim/fallback" + s.model_opus = "open_router/anthropic/claude-opus" + s.model_sonnet = "nvidia_nim/fallback" + s.model_haiku = None + + refs = configured_chat_model_refs(s) + + assert [ref.model_ref for ref in refs] == [ + "nvidia_nim/fallback", + "open_router/anthropic/claude-opus", + ] + assert refs[0].provider_id == "nvidia_nim" + assert refs[0].model_id == "fallback" + assert refs[0].sources == ("MODEL", "MODEL_SONNET") + assert refs[1].provider_id == "open_router" + assert refs[1].model_id == "anthropic/claude-opus" + assert refs[1].sources == ("MODEL_OPUS",) diff --git a/tests/config/test_env_migrations.py b/tests/config/test_env_migrations.py new file mode 100644 index 0000000..5e23a4d --- /dev/null +++ b/tests/config/test_env_migrations.py @@ -0,0 +1,92 @@ +from pathlib import Path + +from free_claude_code.config.env_migrations import ( + HUGGINGFACE_API_KEY_ENV, + HUGGINGFACE_TOKEN_MIGRATION, + LEGACY_HUGGINGFACE_TOKEN_ENV, + env_text_needs_migration, + explicit_env_file_huggingface_warning, + migrate_env_key_in_file, + migrate_env_key_in_text, + migrate_owned_env_files, +) + + +def test_migrate_env_key_in_text_renames_legacy_hf_token() -> None: + text = "# comment\nHF_TOKEN=old-token\nMODEL=nvidia_nim/model\n" + + migrated, changed = migrate_env_key_in_text(text, HUGGINGFACE_TOKEN_MIGRATION) + + assert changed is True + assert migrated == ( + "# comment\nHUGGINGFACE_API_KEY=old-token\nMODEL=nvidia_nim/model\n" + ) + + +def test_migrate_env_key_in_text_preserves_existing_huggingface_api_key() -> None: + text = "HF_TOKEN=old-token\nHUGGINGFACE_API_KEY=new-token\n" + + migrated, changed = migrate_env_key_in_text(text, HUGGINGFACE_TOKEN_MIGRATION) + + assert changed is False + assert migrated == text + + +def test_migrate_env_key_in_text_ignores_comments() -> None: + text = "# HF_TOKEN=old-token\n" + + migrated, changed = migrate_env_key_in_text(text, HUGGINGFACE_TOKEN_MIGRATION) + + assert changed is False + assert migrated == text + assert not env_text_needs_migration(text, HUGGINGFACE_TOKEN_MIGRATION) + + +def test_migrate_env_key_in_file_rewrites_dotenv(tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text("export HF_TOKEN = quoted-token\n", encoding="utf-8") + + assert migrate_env_key_in_file(env_file, HUGGINGFACE_TOKEN_MIGRATION) is True + + assert env_file.read_text(encoding="utf-8") == ( + "export HUGGINGFACE_API_KEY = quoted-token\n" + ) + + +def test_migrate_owned_env_files_rewrites_repo_and_managed_env( + monkeypatch, tmp_path: Path +) -> None: + repo = tmp_path / "repo" + repo.mkdir() + managed = tmp_path / ".fcc" / ".env" + managed.parent.mkdir() + (repo / ".env").write_text("HF_TOKEN=repo-token\n", encoding="utf-8") + managed.write_text("HF_TOKEN=managed-token\n", encoding="utf-8") + monkeypatch.chdir(repo) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + + migrated = migrate_owned_env_files() + + assert migrated == (repo / ".env", managed) + assert (repo / ".env").read_text(encoding="utf-8") == ( + "HUGGINGFACE_API_KEY=repo-token\n" + ) + assert managed.read_text(encoding="utf-8") == ( + "HUGGINGFACE_API_KEY=managed-token\n" + ) + + +def test_explicit_env_file_huggingface_warning_does_not_rewrite( + tmp_path: Path, +) -> None: + explicit = tmp_path / "custom.env" + explicit.write_text("HF_TOKEN=explicit-token\n", encoding="utf-8") + + warning = explicit_env_file_huggingface_warning({"FCC_ENV_FILE": str(explicit)}) + + assert warning is not None + assert str(explicit) in warning + assert LEGACY_HUGGINGFACE_TOKEN_ENV in warning + assert HUGGINGFACE_API_KEY_ENV in warning + assert explicit.read_text(encoding="utf-8") == "HF_TOKEN=explicit-token\n" diff --git a/tests/config/test_logging_config.py b/tests/config/test_logging_config.py new file mode 100644 index 0000000..5bdc0ee --- /dev/null +++ b/tests/config/test_logging_config.py @@ -0,0 +1,103 @@ +"""Tests for config/logging_config.py.""" + +import json +import logging +from pathlib import Path + +from loguru import logger + +from free_claude_code.config.logging_config import configure_logging + + +def test_configure_logging_creates_parent_directories(tmp_path) -> None: + """Nested log path: parent directories are created before truncating.""" + log_file = tmp_path / "nested" / "dir" / "app.log" + configure_logging(str(log_file), force=True) + assert log_file.is_file() + + +def test_configure_logging_writes_json_to_file(tmp_path): + """configure_logging writes JSON lines to the specified file.""" + log_file = str(tmp_path / "test.log") + configure_logging(log_file, force=True) + + # Emit a log via stdlib (intercepted to loguru) + logger = logging.getLogger("test.module") + logger.info("Test message for JSON") + + # Force flush - loguru may buffer + from loguru import logger as loguru_logger + + loguru_logger.complete() + + content = Path(log_file).read_text(encoding="utf-8") + lines = [line for line in content.strip().split("\n") if line] + assert len(lines) >= 1 + + # Each line should be valid JSON + for line in lines: + record = json.loads(line) + assert "text" in record or "message" in record or "record" in record + + +def test_configure_logging_idempotent(tmp_path): + """configure_logging is idempotent - safe to call twice with force.""" + log_file = str(tmp_path / "test.log") + configure_logging(log_file, force=True) + configure_logging(log_file, force=True) # Should not raise + + logger = logging.getLogger("test.idempotent") + logger.info("After second configure") + + +def test_configure_logging_skips_when_already_configured(tmp_path): + """Without force, second call is a no-op (avoids reconfig on hot reload).""" + log_file = str(tmp_path / "test.log") + configure_logging(log_file, force=True) + # Second call without force - should skip; no exception, log file unchanged + configure_logging(str(tmp_path / "other.log"), force=False) + # Logs still go to first file + logger = logging.getLogger("test.skip") + logger.info("Still goes to first file") + from loguru import logger as loguru_logger + + loguru_logger.complete() + assert (tmp_path / "test.log").exists() + assert "Still goes to first file" in (tmp_path / "test.log").read_text( + encoding="utf-8" + ) + + +def test_telegram_bot_token_redacted_in_message_field(tmp_path) -> None: + log_file = str(tmp_path / "redact.log") + configure_logging(log_file, force=True, verbose_third_party=False) + token = "123456:ABCDEF-ghij-klm" + logger.info("Calling {}", f"https://api.telegram.org/bot{token}/getMe") + logger.complete() + text = Path(log_file).read_text(encoding="utf-8") + assert token not in text + assert "bot/" in text or "redacted" in text + + +def test_bearer_substring_redacted_in_log_file(tmp_path) -> None: + log_file = str(tmp_path / "bearer.log") + configure_logging(log_file, force=True, verbose_third_party=False) + secret = "ya29.secret-token-abc" + logger.info("Request headers: Authorization: Bearer {}", secret) + logger.complete() + text = Path(log_file).read_text(encoding="utf-8") + assert secret not in text + assert "Bearer" in text + + +def test_httpx_logger_quieted_when_not_verbose_third_party(tmp_path) -> None: + log_file = str(tmp_path / "quiet.log") + configure_logging(log_file, force=True, verbose_third_party=False) + assert logging.getLogger("httpx").level >= logging.WARNING + assert logging.getLogger("httpcore").level >= logging.WARNING + + +def test_httpx_resets_to_notset_when_verbose_third_party(tmp_path) -> None: + log_file = str(tmp_path / "verbose.log") + configure_logging(log_file, force=True, verbose_third_party=True) + assert logging.getLogger("httpx").level == logging.NOTSET diff --git a/tests/config/test_provider_catalog.py b/tests/config/test_provider_catalog.py new file mode 100644 index 0000000..fe4ca71 --- /dev/null +++ b/tests/config/test_provider_catalog.py @@ -0,0 +1,34 @@ +from dataclasses import FrozenInstanceError + +import pytest + +from free_claude_code.config.provider_catalog import ( + PROVIDER_CATALOG, + ProviderDescriptor, +) + + +def test_provider_descriptors_are_immutable_values() -> None: + descriptor = ProviderDescriptor( + provider_id="local", + display_name="Local", + local=True, + ) + + assert descriptor.local is True + assert not hasattr(descriptor, "__dict__") + with pytest.raises(FrozenInstanceError): + descriptor.__setattr__("local", False) + + +def test_catalog_has_no_transport_metadata() -> None: + assert "transport_type" not in ProviderDescriptor.__slots__ + assert "capabilities" not in ProviderDescriptor.__slots__ + + +def test_catalog_local_assignments_are_exact() -> None: + assert { + provider_id + for provider_id, descriptor in PROVIDER_CATALOG.items() + if descriptor.local + } == {"lmstudio", "llamacpp", "ollama"} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..813e978 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,219 @@ +import asyncio +import contextlib +import logging +import os +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from free_claude_code.config.settings import Settings +from tests.providers.support import passthrough_rate_limiter + +# Set mock environment BEFORE any imports that use Settings +os.environ.setdefault("NVIDIA_NIM_API_KEY", "test_key") +os.environ.setdefault("MODEL", "nvidia_nim/test-model") +os.environ["PTB_TIMEDELTA"] = "1" +# Ensure tests don't pick up a server API key from the repo .env +# (tests expect endpoints to be unauthenticated by default) +os.environ["ANTHROPIC_AUTH_TOKEN"] = "" + +Settings.model_config = {**Settings.model_config, "env_file": None} + + +@pytest.fixture(autouse=True) +def _isolate_from_dotenv(monkeypatch): + """Prevent Pydantic BaseSettings from reading the .env file during tests.""" + monkeypatch.setattr( + Settings, "model_config", {**Settings.model_config, "env_file": None} + ) + + +@pytest.fixture +def provider_config(): + from free_claude_code.providers.base import ProviderConfig + + return ProviderConfig( + api_key="test_key", + base_url="https://test.api.nvidia.com/v1", + rate_limit=10, + rate_window=60, + ) + + +@pytest.fixture +def nim_provider(provider_config): + from free_claude_code.config.nim import NimSettings + from free_claude_code.providers.nvidia_nim import NvidiaNimProvider + + return NvidiaNimProvider( + provider_config, + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + + +@pytest.fixture +def open_router_provider(provider_config): + from free_claude_code.providers.open_router import OpenRouterProvider + + return OpenRouterProvider(provider_config, rate_limiter=passthrough_rate_limiter()) + + +@pytest.fixture +def lmstudio_provider(provider_config): + from free_claude_code.providers.base import ProviderConfig + from free_claude_code.providers.lmstudio import LMStudioProvider + + lmstudio_config = ProviderConfig( + api_key="lm-studio", + base_url="http://localhost:1234/v1", + rate_limit=provider_config.rate_limit, + rate_window=provider_config.rate_window, + ) + return LMStudioProvider(lmstudio_config, rate_limiter=passthrough_rate_limiter()) + + +@pytest.fixture +def llamacpp_provider(provider_config): + from free_claude_code.providers.base import ProviderConfig + from free_claude_code.providers.openai_chat import create_openai_chat_provider + + llamacpp_config = ProviderConfig( + api_key="llamacpp", + base_url="http://localhost:8080/v1", + rate_limit=10, + rate_window=60, + ) + return create_openai_chat_provider( + "llamacpp", + llamacpp_config, + passthrough_rate_limiter(), + ) + + +@pytest.fixture +def mock_cli_session(): + from free_claude_code.messaging.managed_protocols import ( + ManagedClaudeSessionProtocol, + ) + + session = MagicMock(spec=ManagedClaudeSessionProtocol) + session.start_task = MagicMock() # This will return an async generator + session.is_busy = False + return session + + +@pytest.fixture +def mock_cli_manager(): + from free_claude_code.messaging.managed_protocols import ( + ManagedClaudeSessionManagerProtocol, + ) + + manager = MagicMock(spec=ManagedClaudeSessionManagerProtocol) + manager.get_or_create_session = AsyncMock() + manager.register_real_session_id = AsyncMock(return_value=True) + manager.stop_all = AsyncMock() + manager.remove_session = AsyncMock(return_value=True) + manager.get_stats = MagicMock(return_value={"active_sessions": 0}) + return manager + + +@pytest.fixture +def mock_platform(): + from free_claude_code.messaging.platforms.ports import OutboundMessenger + + platform = MagicMock(spec=OutboundMessenger) + platform.send_message = AsyncMock(return_value="msg_123") + platform.edit_message = AsyncMock() + platform.delete_message = AsyncMock() + platform.queue_send_message = AsyncMock(return_value="msg_123") + platform.queue_edit_message = AsyncMock() + platform.queue_delete_messages = AsyncMock() + platform.cancel_pending_voice = AsyncMock(return_value=None) + platform.cancel_all_pending_voices = AsyncMock(return_value=()) + platform.cancel_pending_voices_in_scope = AsyncMock(return_value=()) + + def _fire_and_forget(task): + if asyncio.iscoroutine(task): + # Create a task to avoid "coroutine was never awaited" warning + return asyncio.create_task(task) + return None + + platform.fire_and_forget = MagicMock(side_effect=_fire_and_forget) + return platform + + +@pytest.fixture +def mock_session_store(): + from free_claude_code.messaging.session import SessionStore + + store = MagicMock(spec=SessionStore) + store.save_tree = MagicMock() + store.get_tree = MagicMock(return_value=None) + store.register_node = MagicMock() + store.record_message_id = MagicMock() + store.get_tracked_message_ids_for_chat = MagicMock(return_value=[]) + store.forget_tracked_message_ids = MagicMock() + store.clear_scope = MagicMock() + return store + + +@pytest.fixture +def incoming_message_factory(): + _valid_keys = frozenset( + { + "text", + "chat_id", + "user_id", + "message_id", + "platform", + "reply_to_message_id", + "message_thread_id", + "username", + "timestamp", + "raw_event", + "status_message_id", + } + ) + + def _create(**kwargs): + from free_claude_code.messaging.models import IncomingMessage + + defaults: dict[str, Any] = { + "text": "hello", + "chat_id": "chat_1", + "user_id": "user_1", + "message_id": "msg_1", + "platform": "telegram", + } + defaults.update(kwargs) + if "timestamp" in defaults and isinstance(defaults["timestamp"], str): + from datetime import datetime + + defaults["timestamp"] = datetime.fromisoformat(defaults["timestamp"]) + filtered = {k: v for k, v in defaults.items() if k in _valid_keys} + return IncomingMessage(**filtered) + + return _create + + +@pytest.fixture(autouse=True) +def _propagate_loguru_to_caplog(): + """Route loguru logs to stdlib logging so pytest caplog captures them.""" + from loguru import logger as loguru_logger + + class _PropagateHandler: + def write(self, message): + record = message.record + level = record["level"].no + stdlib_level = min(level, logging.CRITICAL) + py_logger = logging.getLogger(record["name"]) + py_logger.log(stdlib_level, record["message"]) + + handler_id = loguru_logger.add(_PropagateHandler(), format="{message}") + yield + with contextlib.suppress(ValueError): + loguru_logger.remove( + handler_id + ) # Handler already removed (e.g. by test_logging_config) diff --git a/tests/contracts/test_admin_provider_manifest.py b/tests/contracts/test_admin_provider_manifest.py new file mode 100644 index 0000000..36ba118 --- /dev/null +++ b/tests/contracts/test_admin_provider_manifest.py @@ -0,0 +1,119 @@ +"""Ensure admin UI manifest exposes every catalog credential/proxy binding.""" + +from free_claude_code.config.admin.manifest import FIELD_BY_KEY +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.config.settings import Settings + + +def test_provider_catalog_remote_credentials_in_admin_manifest() -> None: + missing: list[str] = [] + wrong_attr: list[str] = [] + + for provider_id, desc in PROVIDER_CATALOG.items(): + if desc.credential_env is None: + continue + if desc.credential_attr is None: + missing.append( + f"{provider_id}: credential_env set but credential_attr missing" + ) + continue + entry = FIELD_BY_KEY.get(desc.credential_env) + if entry is None: + missing.append( + f"{provider_id}: {desc.credential_env} not in admin FIELD_BY_KEY" + ) + continue + if entry.settings_attr != desc.credential_attr: + wrong_attr.append( + f"{provider_id}: {desc.credential_env} maps settings_attr=" + f"{entry.settings_attr!r}, catalog expects " + f"{desc.credential_attr!r}" + ) + + assert not missing and not wrong_attr, "\n".join(missing + wrong_attr) + + +def test_provider_catalog_local_base_urls_in_admin_manifest() -> None: + missing_key: list[str] = [] + wrong_attr: list[str] = [] + + for provider_id, desc in PROVIDER_CATALOG.items(): + if desc.base_url_attr is None: + continue + mf = Settings.model_fields[desc.base_url_attr] + alias = mf.validation_alias + if alias is None: + missing_key.append( + f"{provider_id}: {desc.base_url_attr} has no validation_alias " + "(admin manifest expects env-backed base URL)" + ) + continue + env_key = str(alias) + entry = FIELD_BY_KEY.get(env_key) + if entry is None: + missing_key.append( + f"{provider_id}: base URL env {env_key} not in FIELD_BY_KEY" + ) + continue + if entry.settings_attr != desc.base_url_attr: + wrong_attr.append( + f"{provider_id}: {env_key} maps settings_attr=" + f"{entry.settings_attr!r}, catalog expects {desc.base_url_attr!r}" + ) + + assert not missing_key and not wrong_attr, "\n".join(missing_key + wrong_attr) + + +def test_provider_catalog_proxy_attrs_in_admin_manifest() -> None: + missing_key: list[str] = [] + wrong_attr: list[str] = [] + + for provider_id, desc in PROVIDER_CATALOG.items(): + if desc.proxy_attr is None: + continue + mf = Settings.model_fields[desc.proxy_attr] + alias = mf.validation_alias + if alias is None: + missing_key.append( + f"{provider_id}: {desc.proxy_attr} has no validation_alias " + "(admin manifest expects env-backed proxy)" + ) + continue + env_key = str(alias) + entry = FIELD_BY_KEY.get(env_key) + if entry is None: + missing_key.append( + f"{provider_id}: proxy env {env_key} not in FIELD_BY_KEY" + ) + continue + if entry.settings_attr != desc.proxy_attr: + wrong_attr.append( + f"{provider_id}: {env_key} maps settings_attr=" + f"{entry.settings_attr!r}, catalog expects {desc.proxy_attr!r}" + ) + + assert not missing_key and not wrong_attr, "\n".join(missing_key + wrong_attr) + + +def test_provider_catalog_display_names_are_admin_status_source() -> None: + from free_claude_code.config.admin.status import provider_config_status + from free_claude_code.config.admin.values import load_value_state + + status_by_provider = { + entry["provider_id"]: entry + for entry in provider_config_status(load_value_state()) + } + + assert set(status_by_provider) == set(PROVIDER_CATALOG) + for provider_id, desc in PROVIDER_CATALOG.items(): + assert status_by_provider[provider_id]["display_name"] == desc.display_name + expected_kind = "local" if desc.local else "remote" + assert status_by_provider[provider_id]["kind"] == expected_kind + + +def test_cloudflare_account_id_is_admin_provider_field() -> None: + entry = FIELD_BY_KEY["CLOUDFLARE_ACCOUNT_ID"] + + assert entry.settings_attr == "cloudflare_account_id" + assert entry.section_id == "providers" + assert entry.secret is False diff --git a/tests/contracts/test_architecture_contracts.py b/tests/contracts/test_architecture_contracts.py new file mode 100644 index 0000000..1d7d01f --- /dev/null +++ b/tests/contracts/test_architecture_contracts.py @@ -0,0 +1,64 @@ +import re +import tomllib +from pathlib import Path +from urllib.parse import unquote, urlsplit + + +def test_architecture_document_exists() -> None: + repo_root = Path(__file__).resolve().parents[2] + + assert (repo_root / "ARCHITECTURE.md").is_file() + + +def test_architecture_document_relative_links_resolve() -> None: + repo_root = Path(__file__).resolve().parents[2] + architecture = repo_root / "ARCHITECTURE.md" + text = architecture.read_text(encoding="utf-8") + + missing: list[str] = [] + for match in re.finditer(r"(? None: + repo_root = Path(__file__).resolve().parents[2] + root_example = repo_root / ".env.example" + duplicate_example = ( + repo_root / "src" / "free_claude_code" / "config" / "env.example" + ) + + assert root_example.is_file() + assert not duplicate_example.exists() + + +def test_root_env_example_is_packaged_for_config_template_loader() -> None: + repo_root = Path(__file__).resolve().parents[2] + pyproject = tomllib.loads((repo_root / "pyproject.toml").read_text("utf-8")) + + force_include = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][ + "force-include" + ] + + assert force_include[".env.example"] == "free_claude_code/config/env.example" + + +def test_pyproject_first_party_packages_match_packaged_roots() -> None: + repo_root = Path(__file__).resolve().parents[2] + pyproject = (repo_root / "pyproject.toml").read_text(encoding="utf-8") + match = re.search(r"known-first-party = \[(?P[^\]]+)\]", pyproject) + + assert match is not None + configured = { + item.strip().strip('"') + for item in match.group("items").split(",") + if item.strip() + } + expected = {"free_claude_code", "smoke"} + assert configured == expected diff --git a/tests/contracts/test_capability_map.py b/tests/contracts/test_capability_map.py new file mode 100644 index 0000000..6d8036b --- /dev/null +++ b/tests/contracts/test_capability_map.py @@ -0,0 +1,57 @@ +from smoke.capabilities import ( + CAPABILITY_CONTRACTS, + capability_names, + contracted_feature_ids, +) +from smoke.features import FEATURE_INVENTORY, feature_ids + +EXPECTED_CAPABILITIES = { + "api_compatibility", + "auth", + "cli", + "config", + "extensibility", + "local_providers", + "messaging", + "openrouter", + "persistence", + "provider_routing", + "provider_runtime", + "request_behavior", + "streaming_conversion", + "voice", +} + + +def test_capability_map_covers_every_public_feature() -> None: + assert contracted_feature_ids() == feature_ids() + + +def test_capability_map_has_expected_top_level_groups() -> None: + assert capability_names() == EXPECTED_CAPABILITIES + + +def test_capability_contracts_are_decision_complete() -> None: + known_features = {feature.feature_id: feature for feature in FEATURE_INVENTORY} + + for contract in CAPABILITY_CONTRACTS: + assert contract.feature_id in known_features, contract + assert contract.capability.strip(), contract + assert contract.subfeature.strip(), contract + assert contract.owner.strip(), contract + assert contract.inputs.strip(), contract + assert contract.outputs.strip(), contract + assert contract.failure.strip(), contract + + assert contract.pytest_tests or contract.smoke_tests, contract + + +def test_capability_contract_owners_do_not_reference_removed_request_pipeline() -> None: + stale = [ + contract + for contract in CAPABILITY_CONTRACTS + if "free_claude_code.api.request_pipeline" in contract.owner + or "ApiRequestPipeline" in contract.owner + ] + + assert stale == [] diff --git a/tests/contracts/test_feature_manifest.py b/tests/contracts/test_feature_manifest.py new file mode 100644 index 0000000..3048622 --- /dev/null +++ b/tests/contracts/test_feature_manifest.py @@ -0,0 +1,130 @@ +import re +from pathlib import Path + +from free_claude_code.config.provider_catalog import PROVIDER_CATALOG +from free_claude_code.messaging.platforms.factory import create_messaging_components +from free_claude_code.providers.base import BaseProvider +from free_claude_code.providers.cloudflare import CloudflareProvider +from free_claude_code.providers.deepseek import DeepSeekProvider +from free_claude_code.providers.gemini import GeminiProvider +from free_claude_code.providers.github_models import GitHubModelsProvider +from free_claude_code.providers.lmstudio import LMStudioProvider +from free_claude_code.providers.mistral import MistralProvider +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from free_claude_code.providers.open_router import OpenRouterProvider +from free_claude_code.providers.openai_chat import ( + OPENAI_CHAT_PROFILES, + OpenAIChatProvider, +) +from smoke.features import FEATURE_INVENTORY, README_FEATURES, feature_ids + +VALID_SOURCE = {"readme", "public_surface"} + + +def test_every_readme_feature_has_inventory_entry() -> None: + missing = sorted(set(README_FEATURES) - feature_ids(source="readme")) + extra_readme = sorted(feature_ids(source="readme") - set(README_FEATURES)) + assert not missing, f"README features missing inventory entries: {missing}" + assert not extra_readme, ( + f"README inventory entries not in README_FEATURES: {extra_readme}" + ) + + +def test_readme_provider_table_covers_full_catalog() -> None: + repo_root = Path(__file__).resolve().parents[2] + readme = (repo_root / "README.md").read_text(encoding="utf-8") + provider_section = readme.split("## Choose A Provider", 1)[1].split("\n## ", 1)[0] + rows = [line for line in provider_section.splitlines() if line.startswith("| [")] + + prefixes: list[str] = [] + for row in rows: + example_cell = row.split("|")[3] + match = re.search(r"`([a-z0-9_]+)/", example_cell) + assert match is not None, row + prefixes.append(match.group(1)) + + assert len(rows) == len(PROVIDER_CATALOG) + assert len(prefixes) == len(set(prefixes)) + assert set(prefixes) == set(PROVIDER_CATALOG) + + +def test_feature_inventory_is_unique_and_decision_complete() -> None: + ids = [feature.feature_id for feature in FEATURE_INVENTORY] + assert len(ids) == len(set(ids)) + assert "claude_pick" not in ids + + for feature in FEATURE_INVENTORY: + assert feature.source in VALID_SOURCE, feature + assert feature.title.strip(), feature + assert feature.skip_policy.strip(), feature + assert feature.pytest_contract_tests, feature + assert feature.has_pytest_coverage, feature + if feature.product_e2e_tests: + assert feature.smoke_targets, feature + assert not feature.product_e2e_reason, feature + else: + assert feature.product_e2e_reason.strip(), feature + if feature.live_prereq_tests: + assert feature.smoke_targets, feature + + +def test_feature_inventory_test_owners_exist() -> None: + repo_root = Path(__file__).resolve().parents[2] + pytest_names = _collect_test_names(repo_root / "tests") + smoke_names = _collect_test_names(repo_root / "smoke") + + for feature in FEATURE_INVENTORY: + for owner in feature.pytest_contract_tests: + _assert_owner_exists(owner, repo_root, pytest_names) + for owner in feature.live_prereq_tests + feature.product_e2e_tests: + assert owner in smoke_names or owner in pytest_names, (feature, owner) + + +def test_product_coverage_is_not_satisfied_by_prereq_probes() -> None: + for feature in FEATURE_INVENTORY: + overlap = set(feature.live_prereq_tests) & set(feature.product_e2e_tests) + assert not overlap, (feature.feature_id, sorted(overlap)) + if feature.product_e2e_tests: + assert all("_e2e" in name for name in feature.product_e2e_tests), feature + + +def test_provider_and_platform_registries_include_advertised_builtins() -> None: + specialized_provider_classes = { + "nvidia_nim": NvidiaNimProvider, + "open_router": OpenRouterProvider, + "mistral": MistralProvider, + "deepseek": DeepSeekProvider, + "cloudflare": CloudflareProvider, + "lmstudio": LMStudioProvider, + "github_models": GitHubModelsProvider, + "gemini": GeminiProvider, + } + assert set(OPENAI_CHAT_PROFILES).isdisjoint(specialized_provider_classes) + assert set(PROVIDER_CATALOG) == ( + set(OPENAI_CHAT_PROFILES) | set(specialized_provider_classes) + ) + assert issubclass(OpenAIChatProvider, BaseProvider) + for provider_class in specialized_provider_classes.values(): + assert issubclass(provider_class, BaseProvider) + + assert create_messaging_components("not-a-platform") is None + + +def _collect_test_names(root: Path) -> set[str]: + names: set[str] = set() + for path in root.rglob("test_*.py"): + text = path.read_text(encoding="utf-8") + names.update(re.findall(r"^\s*(?:async\s+)?def (test_[^(]+)", text, re.M)) + return names + + +def _assert_owner_exists(owner: str, repo_root: Path, test_names: set[str]) -> None: + if owner.startswith("test_"): + assert owner in test_names, owner + return + + path_part, _, node_name = owner.partition("::") + path = repo_root / path_part + assert path.exists(), owner + if node_name: + assert node_name in test_names, owner diff --git a/tests/contracts/test_import_boundaries.py b/tests/contracts/test_import_boundaries.py new file mode 100644 index 0000000..11f859c --- /dev/null +++ b/tests/contracts/test_import_boundaries.py @@ -0,0 +1,740 @@ +"""Declarative static import contracts; dynamic imports are deliberately unscanned.""" + +import ast +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_PACKAGE_ROOT = _REPO_ROOT / "src" / "free_claude_code" +_PACKAGE_NAME = "free_claude_code" + +ALLOWED_PACKAGE_DEPENDENCIES: dict[str, set[str]] = { + "config": set(), + "core": set(), + "application": {"config", "core"}, + "messaging": {"core"}, + "providers": {"application", "config", "core"}, + "api": {"application", "config", "core"}, + "cli": {"config", "core"}, + "runtime": { + "api", + "application", + "cli", + "config", + "core", + "messaging", + "providers", + }, +} + +IMPORT_EXCEPTIONS: dict[tuple[str, str], str] = { + ( + "free_claude_code.cli.entrypoints", + "free_claude_code.runtime.bootstrap", + ): ( + "Owner: installed server entrypoint. " + "Reason: the executable delegates construction to the process composition root." + ), +} + +FACADE_ONLY_BOUNDARIES = { + "free_claude_code.core.openai_responses", + "free_claude_code.messaging.trees", + "free_claude_code.providers.openai_chat", +} + +OPTIONAL_IMPORT_OWNERS = { + "librosa": "free_claude_code.messaging.transcription", + "torch": "free_claude_code.messaging.transcription", + "transformers": "free_claude_code.messaging.transcription", + "riva": "free_claude_code.providers.nvidia_nim.voice", +} + + +@dataclass(frozen=True, slots=True) +class ImportRecord: + importer: str + imported: str + path: str + line: int + inside_function: bool + + def describe(self) -> str: + return f"{self.path}:{self.line}: {self.importer} -> {self.imported}" + + +class _ImportVisitor(ast.NodeVisitor): + def __init__( + self, + *, + importer: str, + importer_is_package: bool, + modules: set[str], + path: str, + ) -> None: + self._importer = importer + self._importer_is_package = importer_is_package + self._modules = modules + self._path = path + self._function_depth = 0 + self.records: list[ImportRecord] = [] + + def visit_Import(self, node: ast.Import) -> None: + for alias in node.names: + self._record(alias.name, node.lineno) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + base = _resolve_import_from_base( + importer=self._importer, + importer_is_package=self._importer_is_package, + level=node.level, + module=node.module, + ) + if base is None: + return + for alias in node.names: + candidate = f"{base}.{alias.name}" + imported = candidate if candidate in self._modules else base + self._record(imported, node.lineno) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + self._visit_function(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + self._visit_function(node) + + def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + self._function_depth += 1 + self.generic_visit(node) + self._function_depth -= 1 + + def _record(self, imported: str, line: int) -> None: + self.records.append( + ImportRecord( + importer=self._importer, + imported=imported, + path=self._path, + line=line, + inside_function=self._function_depth > 0, + ) + ) + + +def test_package_dependencies_follow_declarative_policy() -> None: + modules = _module_paths(_PACKAGE_ROOT) + records = _scan_imports(_PACKAGE_ROOT) + actual_packages = _ownership_roots(set(modules), _PACKAGE_NAME) + + assert set(ALLOWED_PACKAGE_DEPENDENCIES) == actual_packages + assert all( + (_PACKAGE_ROOT / package / "__init__.py").is_file() + for package in actual_packages + ) + assert all( + dependency in actual_packages and dependency != package + for package, dependencies in ALLOWED_PACKAGE_DEPENDENCIES.items() + for dependency in dependencies + ) + + observed_dependencies: set[tuple[str, str]] = set() + observed_exceptions: set[tuple[str, str]] = set() + offenders: list[str] = [] + for record in records: + source_package = _top_level_package(record.importer) + target_package = _top_level_package(record.imported) + if source_package is None or target_package is None: + continue + if source_package == target_package: + continue + exact_edge = (record.importer, record.imported) + if exact_edge in IMPORT_EXCEPTIONS: + observed_exceptions.add(exact_edge) + continue + if target_package in ALLOWED_PACKAGE_DEPENDENCIES[source_package]: + observed_dependencies.add((source_package, target_package)) + continue + offenders.append(record.describe()) + + declared_dependencies = { + (package, dependency) + for package, dependencies in ALLOWED_PACKAGE_DEPENDENCIES.items() + for dependency in dependencies + } + assert sorted(offenders) == [] + assert declared_dependencies - observed_dependencies == set() + assert set(IMPORT_EXCEPTIONS) - observed_exceptions == set() + assert all( + "Owner:" in reason and "Reason:" in reason + for reason in IMPORT_EXCEPTIONS.values() + ) + for source, target in IMPORT_EXCEPTIONS: + source_package = _top_level_package(source) + target_package = _top_level_package(target) + assert source_package is not None + assert target_package is not None + assert target_package not in ALLOWED_PACKAGE_DEPENDENCIES[source_package] + expected_package_modules = { + _PACKAGE_NAME, + *(f"{_PACKAGE_NAME}.{package}" for package in actual_packages), + } + assert set(modules) >= expected_package_modules + + +def test_first_party_imports_use_the_installable_namespace() -> None: + offenders = _legacy_first_party_import_offenders( + _scan_imports(_PACKAGE_ROOT), + set(ALLOWED_PACKAGE_DEPENDENCIES), + ) + + assert offenders == [] + + +def test_openai_chat_collaborators_have_explicit_ownership_boundaries() -> None: + provider_root = _PACKAGE_ROOT / "providers" / "openai_chat" + + assert _provider_backchannel_offenders(provider_root) == [] + + +def test_provider_backchannel_detector_reports_untyped_private_access( + tmp_path: Path, +) -> None: + provider_root = tmp_path / "openai_chat" + _write_module( + provider_root / "sample" / "runner.py", + "from typing import Any\n" + "\n" + "class Runner:\n" + " def __init__(self, provider: object | Any) -> None:\n" + " self._provider = provider\n" + "\n" + " def run(self) -> object:\n" + " return self._provider._send()\n", + ) + + assert _provider_backchannel_offenders(provider_root) == [ + "sample/runner.py:4: untyped provider collaborator", + "sample/runner.py:8: private provider member _send outside provider.py", + ] + + +def test_legacy_first_party_import_detector_rejects_bare_owner_names() -> None: + record = ImportRecord( + importer="free_claude_code.api.routes", + imported="core.anthropic", + path="free_claude_code/api/routes.py", + line=7, + inside_function=False, + ) + + assert _legacy_first_party_import_offenders([record], {"api", "core"}) == [ + "free_claude_code/api/routes.py:7: " + "free_claude_code.api.routes -> core.anthropic" + ] + + +def test_ownership_root_discovery_includes_modules_and_namespace_directories( + tmp_path: Path, +) -> None: + package_root = tmp_path / "sample" + _write_module(package_root / "__init__.py") + _write_module(package_root / "declared" / "__init__.py") + _write_module(package_root / "namespace" / "module.py") + _write_module(package_root / "root_module.py") + + roots = _ownership_roots(set(_module_paths(package_root)), "sample") + + assert roots == {"declared", "namespace", "root_module"} + + +def test_descendants_do_not_import_ancestor_package_facades() -> None: + modules = _module_paths(_PACKAGE_ROOT) + packages = { + module for module, path in modules.items() if path.name == "__init__.py" + } + offenders = _ancestor_facade_offenders(_scan_imports(_PACKAGE_ROOT), packages) + + assert offenders == [] + + +def test_static_first_party_import_graph_is_acyclic() -> None: + modules = set(_module_paths(_PACKAGE_ROOT)) + graph = {module: set() for module in modules} + for record in _scan_imports(_PACKAGE_ROOT): + if record.imported in modules: + graph[record.importer].add(record.imported) + + assert _cyclic_components(graph) == [] + + +def test_cycle_detector_reports_exact_strongly_connected_components() -> None: + graph = { + "package.a": {"package.b"}, + "package.b": {"package.a"}, + "package.c": set(), + "package.self": {"package.self"}, + } + + assert _cyclic_components(graph) == [ + ("package.a", "package.b"), + ("package.self",), + ] + + +def test_external_consumers_use_owned_package_facades() -> None: + offenders: list[str] = [] + for record in _scan_imports(_PACKAGE_ROOT): + for facade in FACADE_ONLY_BOUNDARIES: + if record.importer == facade or record.importer.startswith(f"{facade}."): + continue + if record.imported.startswith(f"{facade}."): + offenders.append(record.describe()) + + assert sorted(offenders) == [] + + +def test_import_scanner_resolves_absolute_relative_and_lazy_imports( + tmp_path: Path, +) -> None: + package_root = tmp_path / "sample" + _write_module(package_root / "__init__.py") + _write_module(package_root / "alpha" / "__init__.py", "PUBLIC = object()\n") + _write_module(package_root / "alpha" / "sibling.py") + _write_module(package_root / "beta" / "__init__.py") + _write_module(package_root / "beta" / "absolute.py") + _write_module(package_root / "beta" / "relative.py") + _write_module(package_root / "beta" / "lazy.py") + _write_module( + package_root / "alpha" / "consumer.py", + "\n".join( + ( + "import sample.beta.absolute", + "from ..beta import relative", + "from . import sibling", + "from . import PUBLIC", + "def load():", + " import sample.beta.lazy", + "", + ) + ), + ) + + records = _scan_imports(package_root) + resolved = { + (record.imported, record.inside_function) + for record in records + if record.importer == "sample.alpha.consumer" + } + + assert resolved == { + ("sample.alpha", False), + ("sample.alpha.sibling", False), + ("sample.beta.absolute", False), + ("sample.beta.relative", False), + ("sample.beta.lazy", True), + } + + +def test_import_scanner_distinguishes_facade_symbols_from_sibling_modules( + tmp_path: Path, +) -> None: + package_root = tmp_path / "sample" + _write_module(package_root / "__init__.py") + _write_module(package_root / "owner" / "__init__.py", "PUBLIC = object()\n") + _write_module(package_root / "owner" / "sibling.py") + _write_module( + package_root / "owner" / "consumer.py", + "from . import PUBLIC, sibling\n", + ) + modules = _module_paths(package_root) + packages = { + module for module, path in modules.items() if path.name == "__init__.py" + } + + offenders = _ancestor_facade_offenders(_scan_imports(package_root), packages) + + assert offenders == [ + "sample/owner/consumer.py:1: sample.owner.consumer -> sample.owner" + ] + + +def test_anthropic_request_boundaries_use_the_protocol_model() -> None: + """Known Messages fields must not cross core/provider boundaries by duck typing.""" + roots = [ + _PACKAGE_ROOT / "core" / "anthropic", + _PACKAGE_ROOT / "providers", + ] + request_names = {"request", "request_data"} + protocol_fields = { + "extra_body", + "max_tokens", + "messages", + "model", + "stop_sequences", + "system", + "temperature", + "thinking", + "tool_choice", + "tools", + "top_k", + "top_p", + } + offenders: list[str] = [] + + for root in roots: + for path in root.rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + relative = path.relative_to(_REPO_ROOT).as_posix() + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + arguments = [ + *node.args.posonlyargs, + *node.args.args, + *node.args.kwonlyargs, + ] + for argument in arguments: + if argument.arg.lstrip("_") not in request_names: + continue + annotation_names = ( + { + child.id + for child in ast.walk(argument.annotation) + if isinstance(child, ast.Name) + } + if argument.annotation is not None + else set() + ) + if argument.annotation is None or annotation_names & { + "Any", + "Mapping", + }: + offenders.append( + f"{relative}:{argument.lineno}: " + f"{node.name}({argument.arg}) is not concrete" + ) + if not isinstance(node, ast.Call) or not ( + isinstance(node.func, ast.Name) + and node.func.id == "getattr" + and len(node.args) >= 2 + and isinstance(node.args[0], ast.Name) + and node.args[0].id.lstrip("_") in request_names + and isinstance(node.args[1], ast.Constant) + and node.args[1].value in protocol_fields + ): + continue + offenders.append( + f"{relative}:{node.lineno}: " + f"getattr({node.args[0].id}, {node.args[1].value!r})" + ) + + assert sorted(offenders) == [] + + +def test_core_does_not_import_provider_transport_sdks() -> None: + forbidden_roots = {"aiohttp", "httpx", "openai"} + offenders = [ + record.describe() + for record in _scan_imports(_PACKAGE_ROOT) + if ( + record.importer == "free_claude_code.core" + or record.importer.startswith("free_claude_code.core.") + ) + and record.imported.split(".", 1)[0] in forbidden_roots + ] + + assert sorted(offenders) == [] + + +def test_providers_do_not_own_wire_error_type_literals() -> None: + wire_types = { + "api_error", + "authentication_error", + "billing_error", + "invalid_request_error", + "not_found_error", + "overloaded_error", + "permission_error", + "rate_limit_error", + "request_too_large", + "timeout_error", + } + offenders: list[str] = [] + for path in (_PACKAGE_ROOT / "providers").rglob("*.py"): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + offenders.extend( + f"{path.relative_to(_REPO_ROOT).as_posix()}:{node.lineno}: {node.value}" + for node in ast.walk(tree) + if isinstance(node, ast.Constant) and node.value in wire_types + ) + + assert sorted(offenders) == [] + + +def test_optional_dependencies_have_one_lazy_owner() -> None: + seen: set[str] = set() + offenders: list[str] = [] + for record in _scan_imports(_PACKAGE_ROOT): + dependency = record.imported.split(".", 1)[0] + owner = OPTIONAL_IMPORT_OWNERS.get(dependency) + if owner is None: + continue + seen.add(dependency) + if record.importer != owner or not record.inside_function: + offenders.append(record.describe()) + + assert seen == set(OPTIONAL_IMPORT_OWNERS) + assert sorted(offenders) == [] + + +def test_runtime_imports_without_optional_voice_dependencies() -> None: + blocked = sorted(OPTIONAL_IMPORT_OWNERS) + script = "\n".join( + ( + "import importlib.abc", + "import sys", + f"BLOCKED = {blocked!r}", + "class Blocker(importlib.abc.MetaPathFinder):", + " def find_spec(self, fullname, path=None, target=None):", + " if fullname.split('.', 1)[0] in BLOCKED:", + " raise ModuleNotFoundError(fullname)", + " return None", + "sys.meta_path.insert(0, Blocker())", + "import free_claude_code.runtime.bootstrap", + "import free_claude_code.api.app", + ) + ) + + completed = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + check=False, + text=True, + ) + + assert completed.returncode == 0, completed.stderr or completed.stdout + + +def test_supported_messaging_facade_is_explicit() -> None: + import free_claude_code.messaging as facade + from free_claude_code.messaging.managed_protocols import ( + ManagedClaudeSessionManagerProtocol, + ManagedClaudeSessionProtocol, + ) + from free_claude_code.messaging.models import IncomingMessage, MessageScope + from free_claude_code.messaging.platforms.ports import OutboundMessenger + + expected = { + "IncomingMessage": IncomingMessage, + "ManagedClaudeSessionManagerProtocol": ManagedClaudeSessionManagerProtocol, + "ManagedClaudeSessionProtocol": ManagedClaudeSessionProtocol, + "MessageScope": MessageScope, + "OutboundMessenger": OutboundMessenger, + } + + assert set(facade.__all__) == set(expected) + assert all(getattr(facade, name) is value for name, value in expected.items()) + + +def test_message_tree_mutability_stays_behind_its_facade() -> None: + import free_claude_code.messaging.trees as facade + + for internal_owner in { + "MessageNode", + "MessageTree", + "TreeQueueProcessor", + "TreeRepository", + }: + assert internal_owner not in facade.__all__ + assert not hasattr(facade, internal_owner) + + +def _module_paths(package_root: Path) -> dict[str, Path]: + return { + _module_name(package_root, path): path for path in package_root.rglob("*.py") + } + + +def _module_name(package_root: Path, path: Path) -> str: + relative = path.relative_to(package_root) + module_parts = ( + relative.parent.parts + if path.name == "__init__.py" + else relative.with_suffix("").parts + ) + return ".".join((package_root.name, *module_parts)) + + +def _scan_imports(package_root: Path) -> list[ImportRecord]: + module_paths = _module_paths(package_root) + modules = set(module_paths) + records: list[ImportRecord] = [] + for importer, path in sorted(module_paths.items()): + visitor = _ImportVisitor( + importer=importer, + importer_is_package=path.name == "__init__.py", + modules=modules, + path=path.relative_to(package_root.parent).as_posix(), + ) + visitor.visit(ast.parse(path.read_text(encoding="utf-8"), filename=str(path))) + records.extend(visitor.records) + return records + + +def _resolve_import_from_base( + *, + importer: str, + importer_is_package: bool, + level: int, + module: str | None, +) -> str | None: + if level == 0: + return module + package_parts = importer.split(".") + if not importer_is_package: + package_parts.pop() + parents_to_remove = level - 1 + if parents_to_remove > len(package_parts): + return None + if parents_to_remove: + del package_parts[-parents_to_remove:] + if module is not None: + package_parts.extend(module.split(".")) + return ".".join(package_parts) or None + + +def _top_level_package(module: str) -> str | None: + parts = module.split(".") + if len(parts) < 2 or parts[0] != _PACKAGE_NAME: + return None + return parts[1] + + +def _ownership_roots(modules: set[str], package_name: str) -> set[str]: + roots: set[str] = set() + for module in modules: + parts = module.split(".") + if len(parts) >= 2 and parts[0] == package_name: + roots.add(parts[1]) + return roots + + +def _legacy_first_party_import_offenders( + records: list[ImportRecord], + owner_names: set[str], +) -> list[str]: + offenders = [ + record.describe() + for record in records + if record.imported.split(".", 1)[0] in owner_names + ] + return sorted(offenders) + + +def _provider_backchannel_offenders(provider_root: Path) -> list[str]: + offenders: list[str] = [] + for path in sorted(provider_root.rglob("*.py")): + relative_path = path.relative_to(provider_root).as_posix() + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + arguments = ( + *node.args.posonlyargs, + *node.args.args, + *node.args.kwonlyargs, + ) + offenders.extend( + f"{relative_path}:{argument.lineno}: untyped provider collaborator" + for argument in arguments + if argument.arg == "provider" + and _annotation_is_any(argument.annotation) + ) + if ( + path.name != "provider.py" + and isinstance(node, ast.Attribute) + and node.attr.startswith("_") + and _is_provider_reference(node.value) + ): + offenders.append( + f"{relative_path}:{node.lineno}: private provider member " + f"{node.attr} outside provider.py" + ) + return sorted(offenders) + + +def _annotation_is_any(annotation: ast.expr | None) -> bool: + if annotation is None: + return False + return any( + (isinstance(node, ast.Name) and node.id == "Any") + or (isinstance(node, ast.Attribute) and node.attr == "Any") + for node in ast.walk(annotation) + ) + + +def _is_provider_reference(expression: ast.expr) -> bool: + return (isinstance(expression, ast.Name) and expression.id == "provider") or ( + isinstance(expression, ast.Attribute) + and isinstance(expression.value, ast.Name) + and expression.value.id == "self" + and expression.attr == "_provider" + ) + + +def _ancestor_facade_offenders( + records: list[ImportRecord], + packages: set[str], +) -> list[str]: + offenders = [ + record.describe() + for record in records + if record.imported in packages + and record.importer.startswith(f"{record.imported}.") + ] + return sorted(offenders) + + +def _cyclic_components(graph: dict[str, set[str]]) -> list[tuple[str, ...]]: + index = 0 + indices: dict[str, int] = {} + lowlinks: dict[str, int] = {} + stack: list[str] = [] + on_stack: set[str] = set() + components: list[tuple[str, ...]] = [] + + def visit(node: str) -> None: + nonlocal index + indices[node] = index + lowlinks[node] = index + index += 1 + stack.append(node) + on_stack.add(node) + + for successor in sorted(graph[node]): + if successor not in indices: + visit(successor) + lowlinks[node] = min(lowlinks[node], lowlinks[successor]) + elif successor in on_stack: + lowlinks[node] = min(lowlinks[node], indices[successor]) + + if lowlinks[node] != indices[node]: + return + component: list[str] = [] + while True: + member = stack.pop() + on_stack.remove(member) + component.append(member) + if member == node: + break + if len(component) > 1 or node in graph[node]: + components.append(tuple(sorted(component))) + + for node in sorted(graph): + if node not in indices: + visit(node) + return sorted(components) + + +def _write_module(path: Path, text: str = "") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") diff --git a/tests/contracts/test_local_provider_smoke.py b/tests/contracts/test_local_provider_smoke.py new file mode 100644 index 0000000..d126b19 --- /dev/null +++ b/tests/contracts/test_local_provider_smoke.py @@ -0,0 +1,96 @@ +from dataclasses import dataclass + +import httpx +import pytest + +from smoke.lib.local_providers import first_local_provider_model_id + + +@dataclass(slots=True) +class FakeResponse: + status_code: int + payload: dict + text: str = "" + + def json(self) -> dict: + return self.payload + + +def test_local_provider_openai_models_returns_first_id(monkeypatch) -> None: + calls: list[tuple[str, float]] = [] + + def fake_get(url: str, *, timeout: float) -> FakeResponse: + calls.append((url, timeout)) + return FakeResponse(200, {"data": [{"id": "local-model"}]}) + + monkeypatch.setattr("smoke.lib.local_providers.httpx.get", fake_get) + + model = first_local_provider_model_id( + "lmstudio", + "http://127.0.0.1:1234/v1", + timeout_s=45, + ) + + assert model == "local-model" + assert calls == [("http://127.0.0.1:1234/v1/models", 1.5)] + + +@pytest.mark.parametrize( + ("provider", "port", "model_id"), + [ + ("llamacpp", 8080, "local-model"), + ("ollama", 11434, "llama3.1"), + ], +) +def test_local_provider_root_url_targets_openai_v1_models( + monkeypatch, provider: str, port: int, model_id: str +) -> None: + def fake_get(url: str, *, timeout: float) -> FakeResponse: + assert url == f"http://127.0.0.1:{port}/v1/models" + assert timeout == 1.5 + return FakeResponse(200, {"data": [{"id": model_id}]}) + + monkeypatch.setattr("smoke.lib.local_providers.httpx.get", fake_get) + + assert ( + first_local_provider_model_id( + provider, + f"http://127.0.0.1:{port}", + timeout_s=45, + ) + == model_id + ) + + +def test_local_provider_not_running_is_missing_env_skip(monkeypatch) -> None: + def fake_get(url: str, *, timeout: float) -> FakeResponse: + raise httpx.ConnectError("refused") + + monkeypatch.setattr("smoke.lib.local_providers.httpx.get", fake_get) + + with pytest.raises(pytest.skip.Exception) as excinfo: + first_local_provider_model_id( + "llamacpp", + "http://127.0.0.1:8080/v1", + timeout_s=45, + ) + + assert "missing_env: llamacpp local server is not running" in str(excinfo.value) + + +def test_local_provider_empty_model_list_is_missing_env_skip(monkeypatch) -> None: + def fake_get(url: str, *, timeout: float) -> FakeResponse: + return FakeResponse(200, {"data": []}) + + monkeypatch.setattr("smoke.lib.local_providers.httpx.get", fake_get) + + with pytest.raises(pytest.skip.Exception) as excinfo: + first_local_provider_model_id( + "lmstudio", + "http://127.0.0.1:1234/v1", + timeout_s=45, + ) + + assert "missing_env: lmstudio local server has no loaded models" in str( + excinfo.value + ) diff --git a/tests/contracts/test_nvidia_nim_cli_matrix.py b/tests/contracts/test_nvidia_nim_cli_matrix.py new file mode 100644 index 0000000..8030b0a --- /dev/null +++ b/tests/contracts/test_nvidia_nim_cli_matrix.py @@ -0,0 +1,555 @@ +import json +import subprocess +from pathlib import Path +from typing import cast + +from free_claude_code.config.settings import Settings +from smoke.lib.claude_cli_matrix import ( + ClaudeCliRun, + _build_claude_cli_command, + _subagent_probe_options, + make_outcome, + regression_failures, + run_claude_cli, + write_matrix_report, +) +from smoke.lib.config import DEFAULT_TARGETS, SmokeConfig +from smoke.lib.server import RunningServer + + +def _smoke_config(tmp_path: Path) -> SmokeConfig: + return SmokeConfig( + root=tmp_path, + results_dir=tmp_path / ".smoke-results", + live=False, + interactive=False, + targets=DEFAULT_TARGETS, + provider_matrix=frozenset(), + timeout_s=45.0, + prompt="Reply with exactly: FCC_SMOKE_PONG", + claude_bin="claude", + worker_id="test-worker", + settings=Settings.model_construct(anthropic_auth_token=""), + ) + + +def test_nvidia_nim_cli_matrix_report_shape_and_redaction( + tmp_path: Path, monkeypatch +) -> None: + monkeypatch.setenv("NVIDIA_NIM_API_KEY", "secret-nim-key") + run = ClaudeCliRun( + command=("claude", "-p", "redacted"), + returncode=0, + stdout="FCC_NIM_BASIC secret-nim-key", + stderr="", + duration_s=1.25, + ) + outcome = make_outcome( + model="z-ai/glm-5.2", + full_model="nvidia_nim/z-ai/glm-5.2", + source="nvidia_nim_cli_default", + feature="basic_text", + marker="FCC_NIM_BASIC", + run=run, + log_delta='POST /v1/messages HTTP/1.1" 200 OK secret-nim-key', + log_path=tmp_path / "server.log", + ) + + path = write_matrix_report( + _smoke_config(tmp_path), + [outcome], + target="nvidia_nim_cli", + filename_prefix="nvidia-nim-cli", + ) + payload = json.loads(path.read_text(encoding="utf-8")) + + assert path.name.startswith("nvidia-nim-cli-matrix-test-worker-") + assert payload["target"] == "nvidia_nim_cli" + assert payload["models"] == ["nvidia_nim/z-ai/glm-5.2"] + saved = payload["outcomes"][0] + assert saved["feature"] == "basic_text" + assert saved["classification"] == "passed" + assert saved["request_count"] == 1 + assert saved["token_evidence"]["marker_present"] is True + assert saved["token_evidence"]["agent_catalog_present"] is False + assert saved["token_evidence"]["agent_tool_count"] == 0 + assert saved["token_evidence"]["agent_result_count"] == 0 + assert "secret-nim-key" not in path.read_text(encoding="utf-8") + + +def test_cli_matrix_normalizes_missing_captured_output( + tmp_path: Path, monkeypatch +) -> None: + def fake_run_captured_text( + command: list[str], + **_kwargs: object, + ) -> subprocess.CompletedProcess[str]: + return cast( + subprocess.CompletedProcess[str], + subprocess.CompletedProcess( + args=command, + returncode=0, + stdout=None, + stderr=None, + ), + ) + + monkeypatch.setattr( + "smoke.lib.claude_cli_matrix.run_captured_text", + fake_run_captured_text, + ) + server = RunningServer( + base_url="http://127.0.0.1:9999", + port=9999, + log_path=tmp_path / "server.log", + process=cast(subprocess.Popen[bytes], object()), + ) + + run = run_claude_cli( + claude_bin="claude", + server=server, + config=_smoke_config(tmp_path), + cwd=tmp_path / "workspace", + prompt="hello", + tools="", + ) + outcome = make_outcome( + model="z-ai/glm-5.2", + full_model="nvidia_nim/z-ai/glm-5.2", + source="nvidia_nim_cli_default", + feature="basic_text", + marker="FCC_NIM_BASIC", + run=run, + log_delta='POST /v1/messages HTTP/1.1" 200 OK', + log_path=tmp_path / "server.log", + ) + + assert run.stdout == "" + assert run.stderr == "" + assert outcome.stdout_excerpt == "" + assert outcome.stderr_excerpt == "" + + +def test_openrouter_free_cli_matrix_report_shape_and_redaction( + tmp_path: Path, monkeypatch +) -> None: + monkeypatch.setenv("OPENROUTER_API_KEY", "secret-openrouter-key") + run = ClaudeCliRun( + command=("claude", "-p", "redacted"), + returncode=0, + stdout="FCC_OPENROUTER_FREE_BASIC secret-openrouter-key", + stderr="", + duration_s=1.25, + ) + outcome = make_outcome( + model="openai/gpt-oss-120b:free", + full_model="open_router/openai/gpt-oss-120b:free", + source="openrouter_free_cli_default", + feature="basic_text", + marker="FCC_OPENROUTER_FREE_BASIC", + run=run, + log_delta='POST /v1/messages HTTP/1.1" 200 OK secret-openrouter-key', + log_path=tmp_path / "server.log", + ) + + path = write_matrix_report( + _smoke_config(tmp_path), + [outcome], + target="openrouter_free_cli", + filename_prefix="openrouter-free-cli", + ) + payload = json.loads(path.read_text(encoding="utf-8")) + + assert path.name.startswith("openrouter-free-cli-matrix-test-worker-") + assert payload["target"] == "openrouter_free_cli" + assert payload["models"] == ["open_router/openai/gpt-oss-120b:free"] + saved = payload["outcomes"][0] + assert saved["feature"] == "basic_text" + assert saved["classification"] == "passed" + assert saved["request_count"] == 1 + assert saved["token_evidence"]["marker_present"] is True + assert saved["token_evidence"]["agent_catalog_present"] is False + assert saved["token_evidence"]["agent_tool_count"] == 0 + assert saved["token_evidence"]["agent_result_count"] == 0 + assert "secret-openrouter-key" not in path.read_text(encoding="utf-8") + + +def test_nvidia_nim_cli_matrix_regression_detection(tmp_path: Path) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout="", + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="z-ai/glm-5.2", + full_model="nvidia_nim/z-ai/glm-5.2", + source="nvidia_nim_cli_default", + feature="basic_text", + marker="FCC_NIM_BASIC", + run=run, + log_delta='POST /v1/messages HTTP/1.1" 500 Internal Server Error', + log_path=tmp_path / "server.log", + ) + + assert outcome.classification == "product_failure" + assert regression_failures([outcome]) == [ + "nvidia_nim/z-ai/glm-5.2 basic_text: product_failure" + ] + + +def test_nvidia_nim_cli_matrix_model_feature_failures_do_not_regress( + tmp_path: Path, +) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout="ordinary answer", + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="z-ai/glm-5.2", + full_model="nvidia_nim/z-ai/glm-5.2", + source="nvidia_nim_cli_default", + feature="tool_use_roundtrip", + marker="FCC_NIM_TOOL", + run=run, + log_delta='POST /v1/messages HTTP/1.1" 200 OK', + log_path=tmp_path / "server.log", + requires_tool_result=True, + ) + + assert outcome.classification == "model_feature_failure" + assert regression_failures([outcome]) == [] + + +def test_nvidia_nim_cli_raw_payload_log_counts_as_proxy_request( + tmp_path: Path, +) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout="ordinary answer", + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="z-ai/glm-5.2", + full_model="nvidia_nim/z-ai/glm-5.2", + source="nvidia_nim_cli_default", + feature="subagent_task", + marker="FCC_NIM_TASK", + run=run, + log_delta="API_REQUEST: request_id=req_1 model=z-ai/glm-5.2 messages=2", + log_path=tmp_path / "server.log", + requires_task=True, + ) + + assert outcome.classification == "model_feature_failure" + assert outcome.request_count == 1 + assert regression_failures([outcome]) == [] + + +def test_cli_matrix_missing_agent_catalog_is_harness_bug(tmp_path: Path) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout="ordinary answer", + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="openai/gpt-oss-120b:free", + full_model="open_router/openai/gpt-oss-120b:free", + source="openrouter_free_cli_default", + feature="subagent_task", + marker="FCC_OPENROUTER_FREE_TASK", + run=run, + log_delta=( + "API_REQUEST: request_id=req_1 model=openai/gpt-oss-120b:free " + "messages=1\n" + "FULL_PAYLOAD [req_1]: {'messages': [], 'tools': [{'name': 'Read'}], " + "'tool_choice': None}" + ), + log_path=tmp_path / "server.log", + requires_agent=True, + ) + + assert outcome.classification == "harness_bug" + assert outcome.token_evidence["agent_catalog_present"] is False + + +def test_cli_matrix_agent_catalog_without_agent_use_is_model_feature_failure( + tmp_path: Path, +) -> None: + marker = "FCC_OPENROUTER_FREE_TASK" + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout=( + f'{marker}\n{{"type":"tool_use","name":"Read"}}\n{{"type":"tool_result"}}' + ), + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="openai/gpt-oss-120b:free", + full_model="open_router/openai/gpt-oss-120b:free", + source="openrouter_free_cli_default", + feature="subagent_task", + marker=marker, + run=run, + log_delta=( + "API_REQUEST: request_id=req_1 model=openai/gpt-oss-120b:free " + "messages=1\n" + "FULL_PAYLOAD [req_1]: {'messages': [], 'tools': " + "[{'name': 'Agent'}, {'name': 'Read'}], 'tool_choice': None}" + ), + log_path=tmp_path / "server.log", + requires_tool_result=True, + requires_agent=True, + ) + + assert outcome.classification == "model_feature_failure" + assert outcome.token_evidence["agent_catalog_present"] is True + assert outcome.token_evidence["agent_tool_count"] == 0 + + +def test_cli_matrix_agent_use_result_and_marker_pass(tmp_path: Path) -> None: + marker = "FCC_OPENROUTER_FREE_TASK" + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout=( + f'{marker}\n{{"type":"tool_use","name":"Agent"}}\n' + '{"type":"tool_result","content":"agentId: abc123"}' + ), + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="openai/gpt-oss-120b:free", + full_model="open_router/openai/gpt-oss-120b:free", + source="openrouter_free_cli_default", + feature="subagent_task", + marker=marker, + run=run, + log_delta=( + "API_REQUEST: request_id=req_1 model=openai/gpt-oss-120b:free " + "messages=1\n" + "FULL_PAYLOAD [req_1]: {'messages': [], 'tools': " + "[{'name': 'Agent'}, {'name': 'Read'}], 'tool_choice': None}" + ), + log_path=tmp_path / "server.log", + requires_tool_result=True, + requires_agent=True, + ) + + assert outcome.classification == "passed" + assert outcome.token_evidence["agent_catalog_present"] is True + assert outcome.token_evidence["agent_tool_count"] == 1 + assert outcome.token_evidence["agent_result_count"] == 1 + + +def test_cli_matrix_agent_prompt_text_without_tool_evidence_does_not_pass( + tmp_path: Path, +) -> None: + marker = "FCC_OPENROUTER_FREE_TASK" + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout=f"{marker}\nAgent should read the file.", + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="openai/gpt-oss-120b:free", + full_model="open_router/openai/gpt-oss-120b:free", + source="openrouter_free_cli_default", + feature="subagent_task", + marker=marker, + run=run, + log_delta=( + "API_REQUEST: request_id=req_1 model=openai/gpt-oss-120b:free " + "messages=1\n" + "FULL_PAYLOAD [req_1]: {'messages': [], 'tools': " + "[{'name': 'Agent'}, {'name': 'Read'}], 'tool_choice': None}" + ), + log_path=tmp_path / "server.log", + requires_agent=True, + ) + + assert outcome.classification == "model_feature_failure" + assert outcome.token_evidence["agent_catalog_present"] is True + assert outcome.token_evidence["agent_tool_count"] == 0 + + +def test_cli_matrix_structured_provider_error_is_upstream_unavailable( + tmp_path: Path, +) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout="Provider API request failed. (request_id=req_123)", + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="poolside/laguna-m.1:free", + full_model="open_router/poolside/laguna-m.1:free", + source="openrouter_free_cli_default", + feature="tool_use_roundtrip", + marker="FCC_OPENROUTER_FREE_TOOL", + run=run, + log_delta=( + '{"event": "free_claude_code.api.request.received", "http_method": "POST", ' + '"http_path": "/v1/messages"}\n' + '{"event": "provider.response.error", "exc_type": "HTTPStatusError"}' + ), + log_path=tmp_path / "server.log", + requires_tool_result=True, + ) + + assert outcome.classification == "upstream_unavailable" + assert outcome.request_count == 1 + + +def test_nvidia_nim_cli_timeout_is_not_model_missing( + tmp_path: Path, +) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=None, + stdout='{"type":"assistant","content":[{"type":"text","text":"FCC_NIM_TOOL"}]}', + stderr="", + duration_s=45.0, + timed_out=True, + ) + outcome = make_outcome( + model="z-ai/glm-5.2", + full_model="nvidia_nim/z-ai/glm-5.2", + source="nvidia_nim_cli_default", + feature="tool_use_roundtrip", + marker="FCC_NIM_TOOL", + run=run, + log_delta="API_REQUEST: request_id=req_1 model=z-ai/glm-5.2 messages=2", + log_path=tmp_path / "server.log", + ) + + assert outcome.classification == "probe_timeout" + assert outcome.token_evidence["timed_out"] is True + assert regression_failures([outcome]) == [] + + +def test_nvidia_nim_cli_success_beats_verbose_timeout_words(tmp_path: Path) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout="FCC_NIM_THINK", + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="z-ai/glm-5.2", + full_model="nvidia_nim/z-ai/glm-5.2", + source="nvidia_nim_cli_default", + feature="thinking", + marker="FCC_NIM_THINK", + run=run, + log_delta=( + "API_REQUEST: request_id=req_1 model=z-ai/glm-5.2 messages=1 " + "read_timeout_s=300" + ), + log_path=tmp_path / "server.log", + ) + + assert outcome.classification == "passed" + assert outcome.request_count == 1 + + +def test_cli_matrix_uuid_429_does_not_count_as_upstream_unavailable( + tmp_path: Path, +) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout='{"uuid":"d3c76eea-3634-4299-aec0-e7634b3716da"}', + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="openai/gpt-oss-120b:free", + full_model="open_router/openai/gpt-oss-120b:free", + source="openrouter_free_cli_default", + feature="subagent_task", + marker="FCC_OPENROUTER_FREE_TASK", + run=run, + log_delta="API_REQUEST: request_id=req_1 model=openai/gpt-oss-120b:free messages=2", + log_path=tmp_path / "server.log", + requires_task=True, + ) + + assert outcome.classification == "model_feature_failure" + + +def test_cli_matrix_real_http_429_counts_as_upstream_unavailable( + tmp_path: Path, +) -> None: + run = ClaudeCliRun( + command=("claude", "-p", "x"), + returncode=0, + stdout="ordinary answer", + stderr="", + duration_s=0.1, + ) + outcome = make_outcome( + model="openai/gpt-oss-120b:free", + full_model="open_router/openai/gpt-oss-120b:free", + source="openrouter_free_cli_default", + feature="subagent_task", + marker="FCC_OPENROUTER_FREE_TASK", + run=run, + log_delta=( + "API_REQUEST: request_id=req_1 model=openai/gpt-oss-120b:free " + 'messages=2 upstream HTTP/1.1" 429 Too Many Requests' + ), + log_path=tmp_path / "server.log", + requires_task=True, + ) + + assert outcome.classification == "upstream_unavailable" + + +def test_cli_matrix_default_command_uses_bare_mode() -> None: + command = _build_claude_cli_command( + claude_bin="claude", + prompt="hello", + tools="Read", + ) + + assert command[:2] == ("claude", "--bare") + assert "--tools" in command + assert "Read" in command + + +def test_cli_matrix_subagent_command_uses_agent_without_bare_or_task() -> None: + bare, tools, pre_tool_args, extra_args = _subagent_probe_options("{}") + command = _build_claude_cli_command( + claude_bin="claude", + prompt="hello", + tools=tools, + bare=bare, + pre_tool_args=pre_tool_args, + extra_args=extra_args, + ) + + assert "--bare" not in command + assert command[command.index("--setting-sources") + 1] == "local" + assert "--strict-mcp-config" in command + assert command[command.index("--mcp-config") + 1] == '{"mcpServers":{}}' + assert command[command.index("--tools") + 1] == "Agent,Read" + assert command[command.index("--allowedTools") + 1] == "Agent,Read" + assert command[command.index("--agents") + 1] == "{}" + assert "Task,Read" not in command diff --git a/tests/contracts/test_provider_catalog_order.py b/tests/contracts/test_provider_catalog_order.py new file mode 100644 index 0000000..4da5c9b --- /dev/null +++ b/tests/contracts/test_provider_catalog_order.py @@ -0,0 +1,40 @@ +"""Freeze ``PROVIDER_CATALOG`` insertion order used as canonical provider ranking.""" + +from free_claude_code.config.provider_catalog import ( + PROVIDER_CATALOG, + SUPPORTED_PROVIDER_IDS, +) + +_EXPECTED_PROVIDER_ORDER: tuple[str, ...] = ( + "nvidia_nim", + "open_router", + "gemini", + "deepseek", + "mistral", + "mistral_codestral", + "opencode", + "opencode_go", + "vercel", + "huggingface", + "cohere", + "github_models", + "wafer", + "kimi", + "minimax", + "cerebras", + "groq", + "sambanova", + "fireworks", + "cloudflare", + "zai", + "lmstudio", + "llamacpp", + "ollama", +) + + +def test_provider_catalog_key_order_matches_canonical_plan() -> None: + """NIM first; OpenCode pair stays adjacent; gateways precede native remotes.""" + + assert tuple(PROVIDER_CATALOG.keys()) == _EXPECTED_PROVIDER_ORDER + assert SUPPORTED_PROVIDER_IDS == _EXPECTED_PROVIDER_ORDER diff --git a/tests/contracts/test_smoke_child_process.py b/tests/contracts/test_smoke_child_process.py new file mode 100644 index 0000000..bee5cb9 --- /dev/null +++ b/tests/contracts/test_smoke_child_process.py @@ -0,0 +1,116 @@ +import subprocess +from pathlib import Path + +from free_claude_code.config.settings import Settings +from smoke.lib import child_process +from smoke.lib import server as smoke_server +from smoke.lib.child_process import ( + cmd_free_claude_code_serve, + cmd_python_c, + run_captured_text, +) +from smoke.lib.config import SmokeConfig + + +def test_free_claude_code_serve_command_uses_cli_entrypoint() -> None: + assert cmd_free_claude_code_serve() == [ + child_process.python_exe(), + "-c", + "from free_claude_code.cli.entrypoints import serve; serve()", + ] + + +def test_start_server_disables_cli_admin_browser(monkeypatch, tmp_path: Path) -> None: + captured: dict[str, object] = {} + + class FakeProcess: + def __init__(self, command: list[str], **kwargs: object) -> None: + captured["command"] = command + captured.update(kwargs) + + def poll(self) -> int | None: + return None + + config = SmokeConfig( + root=tmp_path, + results_dir=tmp_path / "results", + live=True, + interactive=False, + targets=frozenset(), + provider_matrix=frozenset(), + timeout_s=1.0, + prompt="", + claude_bin="claude", + worker_id="test", + settings=Settings(), + ) + + monkeypatch.setattr(smoke_server, "find_free_port", lambda: 4567) + monkeypatch.setattr(smoke_server.subprocess, "Popen", FakeProcess) + monkeypatch.setattr( + smoke_server, "_wait_for_health", lambda _server, *, timeout_s: None + ) + monkeypatch.setattr(smoke_server, "_stop_process", lambda _process: None) + + with smoke_server.start_server(config): + pass + + env_obj = captured["env"] + assert isinstance(env_obj, dict) + env = {str(key): value for key, value in env_obj.items()} + assert env["FCC_OPEN_BROWSER"] == "0" + assert env["HOST"] == "127.0.0.1" + assert env["PORT"] == "4567" + + +def test_run_captured_text_uses_utf8_replacement(monkeypatch, tmp_path: Path) -> None: + calls: dict[str, object] = {} + + def fake_run( + command: list[str], + **kwargs: object, + ) -> subprocess.CompletedProcess[str]: + calls["command"] = command + calls.update(kwargs) + return subprocess.CompletedProcess( + args=command, + returncode=0, + stdout="ok", + stderr="", + ) + + monkeypatch.setattr(child_process.subprocess, "run", fake_run) + + result = run_captured_text( + ("cmd", "arg"), + cwd=tmp_path, + env={"FCC_TEST": "1"}, + timeout=1.0, + ) + + assert result.stdout == "ok" + assert calls["command"] == ["cmd", "arg"] + assert calls["cwd"] == tmp_path + assert calls["env"] == {"FCC_TEST": "1"} + assert calls["capture_output"] is True + assert calls["text"] is True + assert calls["encoding"] == "utf-8" + assert calls["errors"] == "replace" + assert calls["timeout"] == 1.0 + assert calls["check"] is False + + +def test_run_captured_text_replaces_invalid_utf8_bytes(tmp_path: Path) -> None: + result = run_captured_text( + cmd_python_c( + "import sys; " + "sys.stdout.buffer.write(bytes([0x8f])); " + "sys.stderr.buffer.write(bytes([0x8f]))" + ), + cwd=tmp_path, + timeout=10.0, + ) + + assert result.returncode == 0 + assert result.stdout == "\ufffd" + assert result.stderr == "\ufffd" diff --git a/tests/contracts/test_smoke_config.py b/tests/contracts/test_smoke_config.py new file mode 100644 index 0000000..bda5569 --- /dev/null +++ b/tests/contracts/test_smoke_config.py @@ -0,0 +1,582 @@ +from pathlib import Path +from types import SimpleNamespace + +from smoke.conftest import ( + DISABLED_PROVIDER_MODEL, + provider_model_params, + provider_xdist_group, +) +from smoke.lib.config import ( + ALL_TARGETS, + DEFAULT_TARGETS, + MISTRAL_REASONING_SMOKE_DEFAULT_MODEL, + NVIDIA_NIM_CLI_DEFAULT_MODELS, + OPENROUTER_FREE_CLI_DEFAULT_MODELS, + OPT_IN_TARGETS, + PROVIDER_SMOKE_DEFAULT_MODELS, + TARGET_REQUIRED_ENV, + SmokeConfig, + nvidia_nim_cli_model_refs, + openrouter_free_cli_model_refs, +) + + +def _settings(**overrides): + values = { + "model": "ollama/llama3.1", + "model_opus": None, + "model_sonnet": None, + "model_haiku": None, + "nvidia_nim_api_key": "", + "open_router_api_key": "", + "mistral_api_key": "", + "codestral_api_key": "", + "deepseek_api_key": "", + "kimi_api_key": "", + "wafer_api_key": "", + "minimax_api_key": "", + "opencode_api_key": "", + "vercel_ai_gateway_api_key": "", + "huggingface_api_key": "", + "cohere_api_key": "", + "github_models_token": "", + "zai_api_key": "", + "gemini_api_key": "", + "groq_api_key": "", + "sambanova_api_key": "", + "cerebras_api_key": "", + "fireworks_api_key": "", + "cloudflare_api_token": "", + "cloudflare_account_id": "", + "lm_studio_base_url": "", + "llamacpp_base_url": "", + "ollama_base_url": "http://localhost:11434", + } + values.update(overrides) + return SimpleNamespace(**values) + + +def _smoke_config(**overrides) -> SmokeConfig: + values = { + "root": Path("."), + "results_dir": Path(".smoke-results"), + "live": False, + "interactive": False, + "targets": DEFAULT_TARGETS, + "provider_matrix": frozenset(), + "timeout_s": 45.0, + "prompt": "Reply with exactly: FCC_SMOKE_PONG", + "claude_bin": "claude", + "worker_id": "main", + "settings": _settings(), + } + values.update(overrides) + return SmokeConfig(**values) + + +def test_ollama_is_default_smoke_target() -> None: + assert "ollama" in DEFAULT_TARGETS + assert "ollama" in TARGET_REQUIRED_ENV + + +def test_nvidia_nim_cli_is_opt_in_smoke_target() -> None: + assert "nvidia_nim_cli" not in DEFAULT_TARGETS + assert "nvidia_nim_cli" in OPT_IN_TARGETS + assert "nvidia_nim_cli" in ALL_TARGETS + assert "nvidia_nim_cli" in TARGET_REQUIRED_ENV + assert "openrouter_free_cli" not in DEFAULT_TARGETS + assert "openrouter_free_cli" in OPT_IN_TARGETS + assert "openrouter_free_cli" in ALL_TARGETS + assert "openrouter_free_cli" in TARGET_REQUIRED_ENV + + +def test_ollama_provider_configuration_uses_base_url() -> None: + config = _smoke_config() + + assert config.has_provider_configuration("ollama") + assert config.provider_models()[0].full_model == "ollama/llama3.1" + + +def test_ollama_provider_matrix_filters_models() -> None: + config = _smoke_config(provider_matrix=frozenset({"ollama"})) + + assert [model.provider for model in config.provider_models()] == ["ollama"] + + +def test_provider_smoke_models_cover_configured_providers_independent_of_model_mapping( + monkeypatch, +) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_DEEPSEEK", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + deepseek_api_key="deepseek-key", + ollama_base_url="", + ) + ) + + models = config.provider_smoke_models() + + assert [model.provider for model in models] == ["deepseek"] + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["deepseek"] + assert models[0].source == "provider_default" + + +def test_openrouter_provider_smoke_uses_concrete_free_model(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_OPEN_ROUTER", raising=False) + config = _smoke_config( + settings=_settings(open_router_api_key="openrouter-key", ollama_base_url="") + ) + + models = config.provider_smoke_models() + + assert [model.provider for model in models] == ["open_router"] + assert models[0].full_model == "open_router/moonshotai/kimi-k2.6:free" + assert models[0].source == "provider_default" + + +def test_wafer_provider_configuration_uses_api_key(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_WAFER", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + wafer_api_key="wafer-key", + ) + ) + + assert config.has_provider_configuration("wafer") + models = config.provider_smoke_models() + assert models[0].provider == "wafer" + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["wafer"] + + +def test_minimax_provider_configuration_uses_api_key(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_MINIMAX", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + minimax_api_key="minimax-key", + ) + ) + + assert config.has_provider_configuration("minimax") + models = config.provider_smoke_models() + assert models[0].provider == "minimax" + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["minimax"] + + +def test_cloudflare_provider_configuration_requires_token_and_account( + monkeypatch, +) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_CLOUDFLARE", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + cloudflare_api_token="cf-token", + cloudflare_account_id="cf-account", + ) + ) + + assert config.has_provider_configuration("cloudflare") + models = config.provider_smoke_models() + assert models[0].provider == "cloudflare" + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["cloudflare"] + + +def test_cloudflare_provider_configuration_missing_account_is_unconfigured() -> None: + config = _smoke_config( + settings=_settings( + ollama_base_url="", + cloudflare_api_token="cf-token", + cloudflare_account_id="", + ) + ) + + assert not config.has_provider_configuration("cloudflare") + + +def test_vercel_provider_configuration_uses_api_key(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_VERCEL", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + vercel_ai_gateway_api_key="vercel-key", + ) + ) + + assert config.has_provider_configuration("vercel") + models = config.provider_smoke_models() + assert models[0].provider == "vercel" + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["vercel"] + + +def test_huggingface_provider_configuration_uses_api_key(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_HUGGINGFACE", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + huggingface_api_key="hf-key", + ) + ) + + assert config.has_provider_configuration("huggingface") + models = config.provider_smoke_models() + assert models[0].provider == "huggingface" + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["huggingface"] + + +def test_cohere_provider_configuration_uses_api_key(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_COHERE", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + cohere_api_key="cohere-key", + ) + ) + + assert config.has_provider_configuration("cohere") + models = config.provider_smoke_models() + assert models[0].provider == "cohere" + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["cohere"] + + +def test_github_models_provider_configuration_uses_token(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_GITHUB_MODELS", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + github_models_token="github-token", + ) + ) + + assert config.has_provider_configuration("github_models") + models = config.provider_smoke_models() + assert models[0].provider == "github_models" + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["github_models"] + + +def test_sambanova_provider_configuration_uses_api_key(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_SAMBANOVA", raising=False) + config = _smoke_config( + settings=_settings( + model="ollama/llama3.1", + ollama_base_url="", + sambanova_api_key="sambanova-key", + ) + ) + + assert config.has_provider_configuration("sambanova") + models = config.provider_smoke_models() + assert models[0].provider == "sambanova" + assert models[0].full_model == PROVIDER_SMOKE_DEFAULT_MODELS["sambanova"] + + +def test_provider_smoke_model_override_accepts_model_name_without_prefix( + monkeypatch, +) -> None: + monkeypatch.setenv("FCC_SMOKE_MODEL_DEEPSEEK", "deepseek-reasoner") + config = _smoke_config( + settings=_settings( + deepseek_api_key="deepseek-key", + ollama_base_url="", + ), + provider_matrix=frozenset({"deepseek"}), + ) + + models = config.provider_smoke_models() + + assert models[0].full_model == "deepseek/deepseek-reasoner" + assert models[0].source == "FCC_SMOKE_MODEL_DEEPSEEK" + + +def test_provider_smoke_model_override_accepts_owner_model_name( + monkeypatch, +) -> None: + monkeypatch.setenv( + "FCC_SMOKE_MODEL_NVIDIA_NIM", "nvidia/nemotron-3-super-120b-a12b" + ) + config = _smoke_config( + settings=_settings( + model="deepseek/deepseek-chat", + deepseek_api_key="", + nvidia_nim_api_key="nim-key", + ollama_base_url="", + ), + provider_matrix=frozenset({"nvidia_nim"}), + ) + + models = config.provider_smoke_models() + + assert models[0].full_model == "nvidia_nim/nvidia/nemotron-3-super-120b-a12b" + assert models[0].source == "FCC_SMOKE_MODEL_NVIDIA_NIM" + + +def test_provider_smoke_model_override_rejects_wrong_provider_prefix( + monkeypatch, +) -> None: + monkeypatch.setenv("FCC_SMOKE_MODEL_DEEPSEEK", "ollama/llama3.1") + config = _smoke_config( + settings=_settings( + deepseek_api_key="deepseek-key", + ollama_base_url="", + ), + provider_matrix=frozenset({"deepseek"}), + ) + + try: + config.provider_smoke_models() + except ValueError as exc: + assert "FCC_SMOKE_MODEL_DEEPSEEK" in str(exc) + else: + raise AssertionError("expected wrong provider prefix to fail") + + +def test_mistral_reasoning_smoke_uses_reasoning_default(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_MISTRAL_REASONING", raising=False) + config = _smoke_config( + settings=_settings(mistral_api_key="mistral-key", ollama_base_url="") + ) + + model = config.mistral_reasoning_smoke_model() + + assert model is not None + assert model.provider == "mistral" + assert model.full_model == MISTRAL_REASONING_SMOKE_DEFAULT_MODEL + assert model.source == "mistral_reasoning_default" + + +def test_mistral_reasoning_smoke_accepts_override(monkeypatch) -> None: + monkeypatch.setenv("FCC_SMOKE_MODEL_MISTRAL_REASONING", "mistral-medium-3-5") + config = _smoke_config( + settings=_settings(mistral_api_key="mistral-key", ollama_base_url="") + ) + + model = config.mistral_reasoning_smoke_model() + + assert model is not None + assert model.full_model == "mistral/mistral-medium-3-5" + assert model.source == "FCC_SMOKE_MODEL_MISTRAL_REASONING" + + +def test_mistral_reasoning_smoke_respects_provider_matrix(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_MISTRAL_REASONING", raising=False) + config = _smoke_config( + settings=_settings(mistral_api_key="mistral-key", ollama_base_url=""), + provider_matrix=frozenset({"deepseek"}), + ) + + assert config.mistral_reasoning_smoke_model() is None + + +def test_provider_smoke_matrix_filters_provider_catalog(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_DEEPSEEK", raising=False) + config = _smoke_config( + settings=_settings( + deepseek_api_key="deepseek-key", + nvidia_nim_api_key="nim-key", + ollama_base_url="", + ), + provider_matrix=frozenset({"nvidia_nim"}), + ) + + assert [model.provider for model in config.provider_smoke_models()] == [ + "nvidia_nim" + ] + + +def test_provider_smoke_collection_params_are_grouped_by_provider( + monkeypatch, +) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_DEEPSEEK", raising=False) + monkeypatch.delenv("FCC_SMOKE_MODEL_NVIDIA_NIM", raising=False) + config = _smoke_config( + live=True, + settings=_settings( + deepseek_api_key="deepseek-key", + nvidia_nim_api_key="nim-key", + ollama_base_url="", + ), + ) + + params = provider_model_params(config) + + assert [param.id for param in params] == ["nvidia_nim", "deepseek"] + groups = [ + mark.args[0] + for param in params + for mark in param.marks + if mark.name == "xdist_group" + ] + assert groups == ["provider:nvidia_nim", "provider:deepseek"] + + +def test_provider_smoke_collection_uses_disabled_placeholder_when_not_live() -> None: + config = _smoke_config(live=False, settings=_settings(ollama_base_url="")) + + params = provider_model_params(config) + + assert [param.values[0] for param in params] == [DISABLED_PROVIDER_MODEL] + assert provider_xdist_group(DISABLED_PROVIDER_MODEL) == "provider:smoke_disabled" + + +def test_provider_smoke_includes_local_provider_when_model_mapping_uses_it( + monkeypatch, +) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_OLLAMA", raising=False) + config = _smoke_config() + + assert [model.provider for model in config.provider_smoke_models()] == ["ollama"] + + +def test_provider_smoke_does_not_include_default_local_urls_when_unmapped( + monkeypatch, +) -> None: + monkeypatch.delenv("FCC_SMOKE_MODEL_OLLAMA", raising=False) + config = _smoke_config(settings=_settings(model="nvidia_nim/test")) + + assert config.provider_smoke_models() == [] + + +def test_nvidia_nim_cli_default_models_are_normalized() -> None: + refs = nvidia_nim_cli_model_refs({}) + + assert tuple(refs) == tuple( + f"nvidia_nim/{model}" for model in NVIDIA_NIM_CLI_DEFAULT_MODELS + ) + assert "nvidia_nim/deepseek-ai/deepseek-v4-pro" in refs + assert "nvidia_nim/deepseek-ai/deepseek-v4-flash" in refs + assert set(refs.values()) == {"nvidia_nim_cli_default"} + + +def test_nvidia_nim_cli_models_override_and_append() -> None: + refs = nvidia_nim_cli_model_refs( + { + "FCC_SMOKE_NIM_MODELS": "z-ai/glm-5.2,nvidia_nim/custom/model", + "FCC_SMOKE_NIM_EXTRA_MODELS": "moonshotai/kimi-k2.6,z-ai/glm-5.2", + } + ) + + assert tuple(refs) == ( + "nvidia_nim/z-ai/glm-5.2", + "nvidia_nim/custom/model", + "nvidia_nim/moonshotai/kimi-k2.6", + ) + assert refs["nvidia_nim/z-ai/glm-5.2"] == "FCC_SMOKE_NIM_MODELS" + assert refs["nvidia_nim/moonshotai/kimi-k2.6"] == ("FCC_SMOKE_NIM_EXTRA_MODELS") + + +def test_nvidia_nim_cli_models_reject_empty_override() -> None: + try: + nvidia_nim_cli_model_refs({"FCC_SMOKE_NIM_MODELS": " , "}) + except ValueError as exc: + assert "FCC_SMOKE_NIM_MODELS" in str(exc) + else: + raise AssertionError("expected empty NVIDIA NIM CLI model override to fail") + + +def test_nvidia_nim_cli_models_reject_wrong_provider_prefix() -> None: + try: + nvidia_nim_cli_model_refs({"FCC_SMOKE_NIM_MODELS": "open_router/model"}) + except ValueError as exc: + assert "nvidia_nim" in str(exc) + else: + raise AssertionError("expected wrong provider prefix to fail") + + +def test_smoke_config_returns_nvidia_nim_cli_provider_models(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_NIM_MODELS", raising=False) + monkeypatch.delenv("FCC_SMOKE_NIM_EXTRA_MODELS", raising=False) + config = _smoke_config( + settings=_settings( + model="nvidia_nim/z-ai/glm-5.2", + nvidia_nim_api_key="nim-key", + ollama_base_url="", + ) + ) + + models = config.nvidia_nim_cli_models() + + assert models[0].provider == "nvidia_nim" + assert models[0].full_model == "nvidia_nim/z-ai/glm-5.2" + assert models[0].source == "nvidia_nim_cli_default" + + +def test_openrouter_free_cli_default_models_are_normalized() -> None: + refs = openrouter_free_cli_model_refs({}) + + assert tuple(refs) == tuple( + f"open_router/{model}" for model in OPENROUTER_FREE_CLI_DEFAULT_MODELS + ) + assert "open_router/nvidia/nemotron-3-super-120b-a12b:free" in refs + assert "open_router/poolside/laguna-m.1:free" in refs + assert set(refs.values()) == {"openrouter_free_cli_default"} + + +def test_openrouter_free_cli_models_override_and_append() -> None: + refs = openrouter_free_cli_model_refs( + { + "FCC_SMOKE_OPENROUTER_FREE_MODELS": ( + "openai/gpt-oss-120b:free,open_router/custom/model:free" + ), + "FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS": ( + "poolside/laguna-m.1:free,openai/gpt-oss-120b:free" + ), + } + ) + + assert tuple(refs) == ( + "open_router/openai/gpt-oss-120b:free", + "open_router/custom/model:free", + "open_router/poolside/laguna-m.1:free", + ) + assert refs["open_router/openai/gpt-oss-120b:free"] == ( + "FCC_SMOKE_OPENROUTER_FREE_MODELS" + ) + assert refs["open_router/poolside/laguna-m.1:free"] == ( + "FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS" + ) + + +def test_openrouter_free_cli_models_reject_empty_override() -> None: + try: + openrouter_free_cli_model_refs({"FCC_SMOKE_OPENROUTER_FREE_MODELS": " , "}) + except ValueError as exc: + assert "FCC_SMOKE_OPENROUTER_FREE_MODELS" in str(exc) + else: + raise AssertionError("expected empty OpenRouter free CLI override to fail") + + +def test_openrouter_free_cli_models_reject_wrong_provider_prefix() -> None: + try: + openrouter_free_cli_model_refs( + {"FCC_SMOKE_OPENROUTER_FREE_MODELS": "nvidia_nim/model"} + ) + except ValueError as exc: + assert "open_router" in str(exc) + else: + raise AssertionError("expected wrong provider prefix to fail") + + +def test_smoke_config_returns_openrouter_free_cli_provider_models(monkeypatch) -> None: + monkeypatch.delenv("FCC_SMOKE_OPENROUTER_FREE_MODELS", raising=False) + monkeypatch.delenv("FCC_SMOKE_OPENROUTER_FREE_EXTRA_MODELS", raising=False) + config = _smoke_config( + settings=_settings( + model="open_router/openai/gpt-oss-120b:free", + open_router_api_key="openrouter-key", + ollama_base_url="", + ) + ) + + models = config.openrouter_free_cli_models() + + assert models[0].provider == "open_router" + assert models[0].full_model == "open_router/nvidia/nemotron-3-super-120b-a12b:free" + assert models[0].source == "openrouter_free_cli_default" diff --git a/tests/contracts/test_smoke_tiers.py b/tests/contracts/test_smoke_tiers.py new file mode 100644 index 0000000..801cf9d --- /dev/null +++ b/tests/contracts/test_smoke_tiers.py @@ -0,0 +1,88 @@ +import json +from pathlib import Path + +import pytest + +from free_claude_code.core.anthropic.stream_contracts import SSEEvent +from smoke.lib.report import classify_outcome +from smoke.lib.report_summary import format_summary, summarize_reports +from smoke.lib.skips import skip_if_upstream_unavailable_events + + +def test_smoke_readme_uses_env_gated_serial_commands() -> None: + repo_root = Path(__file__).resolve().parents[2] + text = (repo_root / "smoke" / "README.md").read_text(encoding="utf-8") + + assert "FCC_LIVE_SMOKE=1" in text + assert "-n 0" in text + assert "-m live" not in text + + +def test_smoke_report_summary_counts_regression_classes(tmp_path: Path) -> None: + report = { + "outcomes": [ + {"classification": "missing_env"}, + {"classification": "product_failure"}, + {"classification": "upstream_unavailable"}, + ] + } + (tmp_path / "report-one.json").write_text(json.dumps(report), encoding="utf-8") + + summary = summarize_reports(tmp_path) + + assert summary.reports == 1 + assert summary.outcomes == 3 + assert summary.classifications["product_failure"] == 1 + assert summary.has_regression + assert "status=regression" in format_summary(summary) + + +def test_target_disabled_skip_is_not_missing_env() -> None: + classification = classify_outcome( + nodeid="smoke/product/test_api_product_live.py::test_api_basic_conversation_e2e", + outcome="skipped", + detail="Skipped: smoke target disabled: api", + ) + + assert classification == "target_disabled" + + +def test_explicit_missing_env_skip_wins_over_network_words() -> None: + classification = classify_outcome( + nodeid="smoke/prereq/test_local_provider_endpoints_prereq_live.py::" + "test_ollama_endpoint_prereq_live", + outcome="skipped", + detail="Skipped: missing_env: ollama local server is not reachable: timed out", + ) + + assert classification == "missing_env" + + +def test_provider_error_text_stream_is_upstream_unavailable_skip() -> None: + events = [ + SSEEvent("message_start", {"message": {"content": []}}, ""), + SSEEvent( + "content_block_start", + {"index": 0, "content_block": {"type": "text", "text": ""}}, + "", + ), + SSEEvent( + "content_block_delta", + { + "index": 0, + "delta": { + "type": "text_delta", + "text": "Upstream provider OPENROUTER returned HTTP 429.", + }, + }, + "", + ), + SSEEvent("content_block_stop", {"index": 0}, ""), + SSEEvent("message_delta", {"delta": {"stop_reason": "end_turn"}}, ""), + SSEEvent("message_stop", {}, ""), + ] + + with pytest.raises(pytest.skip.Exception) as excinfo: + skip_if_upstream_unavailable_events(events) + + assert "upstream_unavailable" in str(excinfo.value) diff --git a/tests/contracts/test_stream_contracts.py b/tests/contracts/test_stream_contracts.py new file mode 100644 index 0000000..4c16455 --- /dev/null +++ b/tests/contracts/test_stream_contracts.py @@ -0,0 +1,194 @@ +"""Stream/SSE contract tests. Strict transcript *ordering* is covered here for +``AnthropicStreamLedger`` output; for integration ordering, add messaging or API +integration tests. +""" + +from collections.abc import Iterable + +from free_claude_code.core.anthropic import ( + AnthropicStreamLedger, + ContentType, + HeuristicToolParser, + ThinkTagParser, +) +from free_claude_code.core.anthropic.stream_contracts import ( + assert_anthropic_stream_contract, + event_names, + parse_sse_text, + text_content, + thinking_content, +) +from free_claude_code.core.anthropic.streaming import format_sse_event + + +def test_interleaved_thinking_text_blocks_are_valid() -> None: + events = _parse_builder_events( + _interleaved_thinking_text_events( + ("first thought", "first answer", "second thought", "final answer") + ) + ) + assert_anthropic_stream_contract(events) + assert event_names(events).count("content_block_start") == 4 + assert thinking_content(events) == "first thoughtsecond thought" + assert text_content(events) == "first answerfinal answer" + + +def test_split_think_tags_preserve_text_and_thinking() -> None: + events = _parse_builder_events( + _events_from_text_chunks(["before hidden", " after"]) + ) + assert_anthropic_stream_contract(events) + assert thinking_content(events) == "hidden" + assert text_content(events) == "before after" + + +def test_mixed_reasoning_content_and_think_tags_keep_order() -> None: + builder = AnthropicStreamLedger("msg_contract", "contract-model") + chunks = [builder.message_start()] + chunks.extend(builder.ensure_thinking_block()) + chunks.append(builder.emit_thinking_delta("reasoning field")) + chunks.extend( + _events_from_text_chunks([" visible tagged done"], builder) + ) + chunks.extend(builder.close_all_blocks()) + chunks.append(builder.message_delta("end_turn", 10)) + chunks.append(builder.message_stop()) + + events = parse_sse_text("".join(chunks)) + assert_anthropic_stream_contract(events) + assert thinking_content(events) == "reasoning fieldtagged" + assert text_content(events) == " visible done" + + +def test_redacted_thinking_block_start_stop_is_valid() -> None: + """Native redacted_thinking uses start/stop only (no deltas).""" + chunks = [ + format_sse_event( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_r", + "type": "message", + "role": "assistant", + "content": [], + "model": "m", + "stop_reason": None, + "stop_sequence": None, + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + }, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "redacted_thinking", "data": "opaque"}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"input_tokens": 1, "output_tokens": 2}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + events = parse_sse_text("".join(chunks)) + assert_anthropic_stream_contract(events) + + +def test_enable_thinking_false_suppresses_reasoning_only() -> None: + events = _parse_builder_events( + _events_from_text_chunks( + ["hello secret world"], enable_thinking=False + ) + ) + assert_anthropic_stream_contract(events) + assert "secret" not in thinking_content(events) + assert text_content(events) == "hello world" + + +def test_task_tool_arguments_force_foreground_execution() -> None: + parser = HeuristicToolParser() + filtered, detected = parser.feed( + "● Inspect" + "true trailing" + ) + detected.extend(parser.flush()) + assert "trailing" in filtered + task = detected[0] + assert task["name"] == "Task" + if isinstance(task.get("input"), dict): + task["input"]["run_in_background"] = False + assert task["input"]["run_in_background"] is False + + +def _interleaved_thinking_text_events( + parts: tuple[str, str, str, str], +) -> Iterable[str]: + builder = AnthropicStreamLedger("msg_contract", "contract-model") + yield builder.message_start() + yield from builder.ensure_thinking_block() + yield builder.emit_thinking_delta(parts[0]) + yield from builder.ensure_text_block() + yield builder.emit_text_delta(parts[1]) + yield from builder.ensure_thinking_block() + yield builder.emit_thinking_delta(parts[2]) + yield from builder.ensure_text_block() + yield builder.emit_text_delta(parts[3]) + yield from builder.close_all_blocks() + yield builder.message_delta("end_turn", 20) + yield builder.message_stop() + + +def _events_from_text_chunks( + chunks: list[str], + builder: AnthropicStreamLedger | None = None, + *, + enable_thinking: bool = True, +) -> list[str]: + sse = builder or AnthropicStreamLedger("msg_contract", "contract-model") + out: list[str] = [] if builder else [sse.message_start()] + parser = ThinkTagParser() + + for chunk in chunks: + out.extend(_emit_parser_parts(sse, parser.feed(chunk), enable_thinking)) + + remaining = parser.flush() + if remaining is not None: + out.extend(_emit_parser_parts(sse, [remaining], enable_thinking)) + + if builder is None: + out.extend(sse.close_all_blocks()) + out.append(sse.message_delta("end_turn", 20)) + out.append(sse.message_stop()) + return out + + +def _emit_parser_parts( + builder: AnthropicStreamLedger, + parts: Iterable, + enable_thinking: bool, +) -> list[str]: + out: list[str] = [] + for part in parts: + if part.type == ContentType.THINKING: + if enable_thinking: + out.extend(builder.ensure_thinking_block()) + out.append(builder.emit_thinking_delta(part.content)) + continue + out.extend(builder.ensure_text_block()) + out.append(builder.emit_text_delta(part.content)) + return out + + +def _parse_builder_events(chunks: Iterable[str]): + return parse_sse_text("".join(chunks)) diff --git a/tests/core/anthropic/test_errors.py b/tests/core/anthropic/test_errors.py new file mode 100644 index 0000000..21e1974 --- /dev/null +++ b/tests/core/anthropic/test_errors.py @@ -0,0 +1,43 @@ +import pytest + +from free_claude_code.core.anthropic import ( + anthropic_error_payload, + anthropic_status_for_error_type, +) + + +@pytest.mark.parametrize( + ("error_type", "status_code"), + [ + ("invalid_request_error", 400), + ("authentication_error", 401), + ("billing_error", 402), + ("permission_error", 403), + ("not_found_error", 404), + ("request_too_large", 413), + ("rate_limit_error", 429), + ("api_error", 500), + ("timeout_error", 504), + ("overloaded_error", 529), + ("future_error", 500), + ], +) +def test_anthropic_status_for_error_type(error_type: str, status_code: int) -> None: + assert anthropic_status_for_error_type(error_type) == status_code + + +def test_anthropic_error_payload_adds_request_id_and_redacts_credentials() -> None: + payload = anthropic_error_payload( + error_type="api_error", + message="failed token=SECRET authorization: Bearer ALSO_SECRET", + request_id="req_test", + ) + + assert payload == { + "type": "error", + "error": { + "type": "api_error", + "message": "failed token= authorization: ", + }, + "request_id": "req_test", + } diff --git a/tests/core/anthropic/test_models.py b/tests/core/anthropic/test_models.py new file mode 100644 index 0000000..10ff809 --- /dev/null +++ b/tests/core/anthropic/test_models.py @@ -0,0 +1,331 @@ +import pytest + +from free_claude_code.core.anthropic.conversion import ( + OpenAIConversionError, + build_base_request_body, +) +from free_claude_code.core.anthropic.models import ( + ContentBlockDocument, + ContentBlockWebFetchToolResult, + Message, + MessagesRequest, + TokenCountRequest, +) + + +def test_messages_request_parses_without_model_mapping_side_effects(): + request = MessagesRequest( + model="claude-3-opus", + max_tokens=100, + messages=[Message(role="user", content="hello")], + ) + + assert request.model == "claude-3-opus" + + +def test_messages_request_normalizes_system_role_messages(): + request = MessagesRequest.model_validate( + { + "model": "claude-3-opus", + "max_tokens": 100, + "messages": [ + {"role": "user", "content": "first"}, + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "second"}, + ], + } + ) + + assert [message.role for message in request.messages] == ["user", "user"] + assert request.system == "system prompt" + + +def test_messages_request_merges_system_role_messages_with_existing_system(): + request = MessagesRequest.model_validate( + { + "model": "claude-3-opus", + "max_tokens": 100, + "system": "existing system", + "messages": [ + {"role": "system", "content": "message system"}, + {"role": "user", "content": "hello"}, + ], + } + ) + + assert len(request.messages) == 1 + assert request.system == "existing system\n\nmessage system" + + +def test_messages_request_preserves_system_block_cache_control_when_normalizing(): + request = MessagesRequest.model_validate( + { + "model": "claude-3-opus", + "max_tokens": 100, + "system": [ + { + "type": "text", + "text": "existing system", + "cache_control": {"type": "ephemeral"}, + } + ], + "messages": [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "message system", + "cache_control": {"type": "ephemeral"}, + } + ], + }, + {"role": "user", "content": "hello"}, + ], + } + ) + + assert len(request.messages) == 1 + assert isinstance(request.system, list) + assert [block.text for block in request.system] == [ + "existing system", + "message system", + ] + assert request.system[0].model_dump()["cache_control"] == {"type": "ephemeral"} + assert request.system[1].model_dump()["cache_control"] == {"type": "ephemeral"} + + +def test_messages_request_ignores_internal_routing_fields_when_supplied(): + request = MessagesRequest.model_validate( + { + "model": "target-model", + "original_model": "claude-3-opus", + "resolved_provider_model": "nvidia_nim/target-model", + "max_tokens": 100, + "messages": [{"role": "user", "content": "hello"}], + } + ) + + assert request.model == "target-model" + assert "original_model" not in request.model_dump() + assert "resolved_provider_model" not in request.model_dump() + + +def test_token_count_request_parses_without_model_mapping_side_effects(): + request = TokenCountRequest( + model="claude-3-sonnet", messages=[Message(role="user", content="hello")] + ) + + assert request.model == "claude-3-sonnet" + + +def test_token_count_request_normalizes_system_role_messages(): + request = TokenCountRequest.model_validate( + { + "model": "claude-3-sonnet", + "messages": [ + {"role": "system", "content": "counting system"}, + {"role": "user", "content": "hello"}, + ], + } + ) + + assert len(request.messages) == 1 + assert request.messages[0].role == "user" + assert request.system == "counting system" + + +def test_messages_request_preserves_thinking_signature(): + request = MessagesRequest.model_validate( + { + "model": "claude-3-opus", + "max_tokens": 100, + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "signed thought", + "signature": "sig_123", + } + ], + } + ], + } + ) + + dumped = request.model_dump(exclude_none=True) + + assert dumped["messages"][0]["content"][0]["signature"] == "sig_123" + + +def test_messages_request_preserves_native_thinking_budget(): + request = MessagesRequest.model_validate( + { + "model": "claude-3-opus", + "max_tokens": 100, + "messages": [{"role": "user", "content": "think hard"}], + "thinking": {"type": "enabled", "budget_tokens": 4096}, + } + ) + + dumped = request.model_dump(exclude_none=True) + + assert dumped["thinking"]["type"] == "enabled" + assert dumped["thinking"]["budget_tokens"] == 4096 + + +def test_messages_request_accepts_adaptive_thinking_type(): + request = MessagesRequest.model_validate( + { + "model": "claude-3-opus", + "max_tokens": 100, + "messages": [{"role": "user", "content": "hello"}], + "thinking": {"type": "adaptive"}, + } + ) + + dumped = request.model_dump(exclude_none=True) + + assert dumped["thinking"]["type"] == "adaptive" + + +def test_messages_request_accepts_anthropic_server_tool_without_input_schema(): + request = MessagesRequest.model_validate( + { + "model": "claude-opus-4-7", + "max_tokens": 100, + "messages": [{"role": "user", "content": "search"}], + "tools": [{"type": "web_search_20250305", "name": "web_search"}], + } + ) + + dumped = request.model_dump(exclude_none=True) + + assert dumped["tools"] == [{"name": "web_search", "type": "web_search_20250305"}] + + +def test_messages_request_accepts_redacted_thinking_blocks(): + request = MessagesRequest.model_validate( + { + "model": "claude-3-opus", + "max_tokens": 100, + "messages": [ + { + "role": "assistant", + "content": [{"type": "redacted_thinking", "data": "opaque"}], + } + ], + } + ) + + dumped = request.model_dump(exclude_none=True) + + assert dumped["messages"][0]["content"][0] == { + "type": "redacted_thinking", + "data": "opaque", + } + + +def test_document_and_web_fetch_blocks_preserve_protocol_extensions() -> None: + request = MessagesRequest.model_validate( + { + "model": "model", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "document", + "source": {"type": "base64", "data": "encoded"}, + "cache_control": {"type": "ephemeral"}, + }, + { + "type": "web_fetch_tool_result", + "tool_use_id": "srvtoolu_1", + "content": {"url": "https://example.com"}, + "provider_extension": True, + }, + ], + } + ], + } + ) + + content = request.messages[0].content + assert isinstance(content, list) + assert isinstance(content[0], ContentBlockDocument) + assert content[0].model_dump()["cache_control"] == {"type": "ephemeral"} + assert isinstance(content[1], ContentBlockWebFetchToolResult) + assert content[1].model_dump()["provider_extension"] is True + + +def test_content_block_descriptions_remain_in_the_public_schema() -> None: + definitions = MessagesRequest.model_json_schema()["$defs"] + + assert definitions["ContentBlockDocument"]["description"] == ( + "Anthropic document block (e.g. PDF files via the Files API)." + ) + assert definitions["ContentBlockServerToolUse"]["description"] == ( + "Anthropic server-side tool invocation (e.g. ``web_search``, ``web_fetch``)." + ) + + +def test_messages_request_dump_preserves_public_defaults_and_excludes_internal_fields() -> ( + None +): + request = MessagesRequest.model_validate( + { + "model": "model", + "messages": [{"role": "user", "content": "hello"}], + "thinking": {"type": "adaptive"}, + "original_model": "original", + "resolved_provider_model": "provider/model", + "betas": ["feature-beta"], + "client_extension": {"enabled": True}, + } + ) + + dumped = request.model_dump(exclude_none=True) + + assert dumped == { + "model": "model", + "messages": [{"role": "user", "content": "hello"}], + "stream": True, + "thinking": {"enabled": True, "type": "adaptive"}, + "client_extension": {"enabled": True}, + } + + +def test_token_count_request_accepts_extras_but_excludes_internal_fields() -> None: + request = TokenCountRequest.model_validate( + { + "model": "model", + "messages": [{"role": "user", "content": "hello"}], + "original_model": "original", + "resolved_provider_model": "provider/model", + "betas": ["feature-beta"], + "client_extension": "accepted", + } + ) + + assert request.model_extra == {"client_extension": "accepted"} + assert request.model_dump(exclude_none=True) == { + "model": "model", + "messages": [{"role": "user", "content": "hello"}], + "client_extension": "accepted", + } + + +def test_openai_conversion_rejects_unknown_top_level_anthropic_extensions() -> None: + request = MessagesRequest.model_validate( + { + "model": "model", + "messages": [{"role": "user", "content": "hello"}], + "client_extension": True, + } + ) + + with pytest.raises(OpenAIConversionError, match="client_extension"): + build_base_request_body(request) diff --git a/tests/core/anthropic/test_request_serialization.py b/tests/core/anthropic/test_request_serialization.py new file mode 100644 index 0000000..3a9ffbb --- /dev/null +++ b/tests/core/anthropic/test_request_serialization.py @@ -0,0 +1,104 @@ +"""Anthropic request parsing and public-field serialization.""" + +from free_claude_code.core.anthropic import dump_messages_request +from free_claude_code.core.anthropic.models import ( + ContentBlockServerToolUse, + ContentBlockText, + ContentBlockWebSearchToolResult, + Message, + MessagesRequest, +) + + +def test_dump_preserves_public_fields_and_nested_extensions() -> None: + request = MessagesRequest.model_validate( + { + "model": "m", + "max_tokens": 20, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "hi", + "cache_control": {"type": "ephemeral"}, + } + ], + } + ], + "context_management": {"edits": [{"type": "clear"}]}, + "output_config": {"some": "hint"}, + } + ) + + body = dump_messages_request(request) + + assert body["messages"][0]["content"][0]["cache_control"] == {"type": "ephemeral"} + assert body["context_management"] == {"edits": [{"type": "clear"}]} + assert body["output_config"] == {"some": "hint"} + + +def test_dump_excludes_unknown_client_hints_and_fcc_routing_state() -> None: + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "reasoning_effort": "none", + "unknown_client_hint": {"mode": "local"}, + } + ) + request.original_model = "claude" + request.resolved_provider_model = "upstream" + + body = dump_messages_request(request) + + assert "reasoning_effort" not in body + assert "unknown_client_hint" not in body + assert "original_model" not in body + assert "resolved_provider_model" not in body + + +def test_pydantic_discriminator_still_distinguishes_blocks() -> None: + message = Message.model_validate( + { + "role": "user", + "content": [{"type": "text", "text": "a", "z": 1}], + } + ) + + block = message.content[0] + + assert isinstance(block, ContentBlockText) + assert block.model_dump()["z"] == 1 + + +def test_server_tool_history_remains_valid_anthropic_input() -> None: + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "server_tool_use", + "id": "srvtoolu_1", + "name": "web_search", + "input": {"query": "q"}, + }, + { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_1", + "content": [], + }, + ], + } + ], + } + ) + + blocks = request.messages[0].content + assert isinstance(blocks, list) + assert isinstance(blocks[0], ContentBlockServerToolUse) + assert isinstance(blocks[1], ContentBlockWebSearchToolResult) diff --git a/tests/core/anthropic/test_response_models.py b/tests/core/anthropic/test_response_models.py new file mode 100644 index 0000000..4889411 --- /dev/null +++ b/tests/core/anthropic/test_response_models.py @@ -0,0 +1,201 @@ +"""Tests for Anthropic protocol response models.""" + +from free_claude_code.core.anthropic.models import ( + ContentBlockText, + ContentBlockThinking, + ContentBlockToolUse, + MessagesResponse, + TokenCountResponse, + Usage, +) + + +class TestUsage: + """Tests for Usage model.""" + + def test_required_fields(self): + usage = Usage(input_tokens=10, output_tokens=20) + assert usage.input_tokens == 10 + assert usage.output_tokens == 20 + + def test_cache_defaults_zero(self): + usage = Usage(input_tokens=1, output_tokens=2) + assert usage.cache_creation_input_tokens == 0 + assert usage.cache_read_input_tokens == 0 + + def test_cache_fields_set(self): + usage = Usage( + input_tokens=10, + output_tokens=20, + cache_creation_input_tokens=5, + cache_read_input_tokens=3, + ) + assert usage.cache_creation_input_tokens == 5 + assert usage.cache_read_input_tokens == 3 + + def test_serialization(self): + usage = Usage(input_tokens=10, output_tokens=20) + data = usage.model_dump() + assert data == { + "input_tokens": 10, + "output_tokens": 20, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + } + + +class TestTokenCountResponse: + """Tests for TokenCountResponse model.""" + + def test_basic(self): + resp = TokenCountResponse(input_tokens=42) + assert resp.input_tokens == 42 + + def test_serialization(self): + resp = TokenCountResponse(input_tokens=100) + data = resp.model_dump() + assert data == {"input_tokens": 100} + + +class TestMessagesResponse: + """Tests for MessagesResponse model.""" + + def test_minimum_fields(self): + resp = MessagesResponse( + id="msg_001", + model="test-model", + content=[ContentBlockText(type="text", text="Hello")], + usage=Usage(input_tokens=10, output_tokens=5), + ) + assert resp.id == "msg_001" + assert resp.model == "test-model" + assert resp.role == "assistant" + assert resp.type == "message" + assert resp.stop_reason is None + assert resp.stop_sequence is None + + def test_with_text_content(self): + resp = MessagesResponse( + id="msg_002", + model="model", + content=[ContentBlockText(type="text", text="response")], + usage=Usage(input_tokens=1, output_tokens=1), + ) + assert len(resp.content) == 1 + block = resp.content[0] + assert isinstance(block, ContentBlockText) + assert block.type == "text" + assert block.text == "response" + + def test_with_tool_use_content(self): + resp = MessagesResponse( + id="msg_003", + model="model", + content=[ + ContentBlockToolUse( + type="tool_use", + id="tool_1", + name="Read", + input={"path": "test.py"}, + ) + ], + usage=Usage(input_tokens=1, output_tokens=1), + stop_reason="tool_use", + ) + block = resp.content[0] + assert isinstance(block, ContentBlockToolUse) + assert block.type == "tool_use" + assert block.name == "Read" + assert resp.stop_reason == "tool_use" + + def test_with_thinking_content(self): + resp = MessagesResponse( + id="msg_004", + model="model", + content=[ + ContentBlockThinking(type="thinking", thinking="Let me reason..."), + ContentBlockText(type="text", text="Answer"), + ], + usage=Usage(input_tokens=5, output_tokens=10), + ) + assert len(resp.content) == 2 + block0 = resp.content[0] + assert isinstance(block0, ContentBlockThinking) + assert block0.type == "thinking" + assert block0.thinking == "Let me reason..." + block1 = resp.content[1] + assert isinstance(block1, ContentBlockText) + assert block1.type == "text" + + def test_with_all_content_types(self): + resp = MessagesResponse( + id="msg_005", + model="model", + content=[ + ContentBlockThinking(type="thinking", thinking="hmm"), + ContentBlockText(type="text", text="result"), + ContentBlockToolUse( + type="tool_use", id="t1", name="Bash", input={"command": "ls"} + ), + ], + usage=Usage(input_tokens=10, output_tokens=20), + stop_reason="tool_use", + ) + assert len(resp.content) == 3 + + def test_with_dict_content(self): + """Dict content (unknown block type) should be accepted.""" + resp = MessagesResponse( + id="msg_006", + model="model", + content=[{"type": "custom", "data": "value"}], + usage=Usage(input_tokens=1, output_tokens=1), + ) + block = resp.content[0] + assert isinstance(block, dict) + assert block["type"] == "custom" + + def test_stop_reason_values(self): + """All valid stop_reason values should be accepted.""" + from typing import Literal + + reasons: list[ + Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"] + ] = [ + "end_turn", + "max_tokens", + "stop_sequence", + "tool_use", + ] + for reason in reasons: + resp = MessagesResponse( + id="msg", + model="model", + content=[ContentBlockText(type="text", text="x")], + usage=Usage(input_tokens=1, output_tokens=1), + stop_reason=reason, + ) + assert resp.stop_reason == reason + + def test_serialization_round_trip(self): + resp = MessagesResponse( + id="msg_rt", + model="model-v1", + content=[ContentBlockText(type="text", text="hello")], + usage=Usage(input_tokens=10, output_tokens=5), + stop_reason="end_turn", + ) + data = resp.model_dump() + restored = MessagesResponse(**data) + assert restored.id == resp.id + assert restored.model == resp.model + assert restored.stop_reason == resp.stop_reason + + def test_empty_content_list(self): + resp = MessagesResponse( + id="msg_empty", + model="model", + content=[], + usage=Usage(input_tokens=0, output_tokens=0), + ) + assert resp.content == [] diff --git a/tests/core/anthropic/test_stream_ledger.py b/tests/core/anthropic/test_stream_ledger.py new file mode 100644 index 0000000..2ebdb9b --- /dev/null +++ b/tests/core/anthropic/test_stream_ledger.py @@ -0,0 +1,126 @@ +"""Tests for the provider-neutral Anthropic stream ledger.""" + +import json +from unittest.mock import patch + +import pytest + +from free_claude_code.core.anthropic.streaming import ( + AnthropicStreamLedger, + StreamBlockLedger, + ToolSchema, + map_stop_reason, +) + + +def _payload(event: str) -> dict: + return json.loads( + next(line[6:] for line in event.splitlines() if line.startswith("data:")) + ) + + +@pytest.mark.parametrize( + ("upstream", "anthropic"), + [ + ("stop", "end_turn"), + ("length", "max_tokens"), + ("tool_calls", "tool_use"), + ("content_filter", "end_turn"), + (None, "end_turn"), + ], +) +def test_map_stop_reason(upstream: str | None, anthropic: str) -> None: + assert map_stop_reason(upstream) == anthropic + + +def test_stream_block_ledger_allocates_monotonic_indexes() -> None: + blocks = StreamBlockLedger() + + assert (blocks.allocate_index(), blocks.allocate_index()) == (0, 1) + + +def test_message_lifecycle() -> None: + ledger = AnthropicStreamLedger("msg_1", "model", input_tokens=7) + + start = _payload(ledger.message_start()) + delta = _payload(ledger.message_delta("end_turn", 3)) + stop = _payload(ledger.message_stop()) + + assert start["message"]["id"] == "msg_1" + assert start["message"]["usage"]["input_tokens"] == 7 + assert delta["delta"]["stop_reason"] == "end_turn" + assert delta["usage"]["output_tokens"] == 3 + assert stop == {"type": "message_stop"} + assert ledger.has_terminal_message() + + +def test_text_and_thinking_blocks_accumulate_content() -> None: + ledger = AnthropicStreamLedger("msg_1", "model") + + ledger.start_thinking_block() + ledger.emit_thinking_delta("step") + ledger.stop_thinking_block() + ledger.start_text_block() + ledger.emit_text_delta("answer") + ledger.stop_text_block() + + assert ledger.accumulated_reasoning == "step" + assert ledger.accumulated_text == "answer" + + +def test_ensure_block_switches_close_the_previous_kind() -> None: + ledger = AnthropicStreamLedger("msg_1", "model") + ledger.start_thinking_block() + + events = list(ledger.ensure_text_block()) + + assert [_payload(event)["type"] for event in events] == [ + "content_block_stop", + "content_block_start", + ] + assert not ledger.blocks.thinking_started + assert ledger.blocks.text_started + + +def test_tool_blocks_drive_stop_reason_and_salvage() -> None: + ledger = AnthropicStreamLedger("msg_1", "model") + ledger.start_tool_block(0, "toolu_1", "Read") + ledger.emit_tool_delta(0, '{"path":"test.py"}') + + assert ledger.final_stop_reason("end_turn") == "tool_use" + assert ledger.can_salvage_tool_use( + { + "Read": ToolSchema( + name="Read", + input_schema={ + "type": "object", + "properties": {"path": {"type": "string"}}, + }, + ) + } + ) + + +def test_close_unclosed_blocks_closes_each_block_once() -> None: + ledger = AnthropicStreamLedger("msg_1", "model") + ledger.start_text_block() + ledger.start_tool_block(0, "toolu_1", "Read") + + events = list(ledger.close_unclosed_blocks()) + + assert len(events) == 2 + assert all(_payload(event)["type"] == "content_block_stop" for event in events) + assert list(ledger.close_unclosed_blocks()) == [] + + +def test_output_token_estimate_uses_encoder_when_available() -> None: + ledger = AnthropicStreamLedger("msg_1", "model") + ledger.start_text_block() + ledger.emit_text_delta("abcd") + + class Encoder: + def encode(self, text: str) -> list[int]: + return list(range(len(text))) + + with patch("free_claude_code.core.anthropic.streaming.ledger.ENCODER", Encoder()): + assert ledger.estimate_output_tokens() == 8 diff --git a/tests/core/anthropic/test_stream_repair.py b/tests/core/anthropic/test_stream_repair.py new file mode 100644 index 0000000..1f8c7e3 --- /dev/null +++ b/tests/core/anthropic/test_stream_repair.py @@ -0,0 +1,47 @@ +"""Neutral Anthropic continuation and tool-repair helpers.""" + +from free_claude_code.core.anthropic.streaming import ( + ToolSchema, + accept_tool_json_repair, + continuation_suffix, +) + + +def test_continuation_suffix_trims_overlap() -> None: + assert continuation_suffix("hello wor", "world") == "ld" + assert continuation_suffix("alpha", "alpha beta") == " beta" + assert continuation_suffix("", "fresh") == "fresh" + + +def test_tool_json_repair_requires_append_only_schema_valid_json() -> None: + schemas = { + "Echo": ToolSchema( + name="Echo", + input_schema={ + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + "additionalProperties": False, + }, + ) + } + + accepted = accept_tool_json_repair( + '{"message":', + '"ok"}', + tool_name="Echo", + schemas=schemas, + ) + assert accepted is not None + assert accepted.suffix == '"ok"}' + assert accepted.parsed_input == {"message": "ok"} + + assert ( + accept_tool_json_repair( + '{"message":', + "1}", + tool_name="Echo", + schemas=schemas, + ) + is None + ) diff --git a/tests/core/openai_responses/test_conversion.py b/tests/core/openai_responses/test_conversion.py new file mode 100644 index 0000000..60619e1 --- /dev/null +++ b/tests/core/openai_responses/test_conversion.py @@ -0,0 +1,629 @@ +from typing import Any + +import pytest + +from free_claude_code.core.openai_responses import ( + OpenAIResponsesAdapter, + OpenAIResponsesRequest, +) + +_ADAPTER = OpenAIResponsesAdapter() +_CONVERSION_ERROR = OpenAIResponsesAdapter.ConversionError + + +def _to_anthropic_payload(request: dict[str, Any]) -> dict[str, Any]: + return _ADAPTER.to_anthropic_payload(OpenAIResponsesRequest.model_validate(request)) + + +def test_responses_string_input_converts_to_anthropic_message() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "instructions": "System instructions", + "input": "Hello", + "max_output_tokens": 64, + "temperature": 0.2, + "top_p": 0.9, + "metadata": {"trace": "abc"}, + } + ) + + assert payload["model"] == "nvidia_nim/test-model" + assert payload["system"] == "System instructions" + assert payload["messages"] == [{"role": "user", "content": "Hello"}] + assert payload["max_tokens"] == 64 + assert payload["temperature"] == 0.2 + assert payload["top_p"] == 0.9 + assert payload["metadata"] == {"trace": "abc"} + + +def test_responses_messages_tools_and_tool_results_convert() -> None: + payload = _to_anthropic_payload( + { + "model": "deepseek/deepseek-chat", + "input": [ + { + "type": "message", + "role": "developer", + "content": [{"type": "input_text", "text": "Developer rules"}], + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Use the tool"}], + }, + { + "type": "function_call", + "call_id": "call_1", + "name": "echo", + "arguments": '{"value":"FCC"}', + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "FCC", + }, + ], + "tools": [ + { + "type": "function", + "name": "echo", + "description": "Echo a value", + "parameters": { + "type": "object", + "properties": {"value": {"type": "string"}}, + }, + } + ], + "tool_choice": {"type": "function", "name": "echo"}, + } + ) + + assert payload["system"] == "Developer rules" + assert payload["messages"] == [ + {"role": "user", "content": [{"type": "text", "text": "Use the tool"}]}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_1", + "name": "echo", + "input": {"value": "FCC"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_1", + "content": "FCC", + } + ], + }, + ] + assert payload["tools"] == [ + { + "name": "echo", + "description": "Echo a value", + "input_schema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + }, + } + ] + assert payload["tool_choice"] == {"type": "tool", "name": "echo"} + + +def test_responses_tool_choice_none_disables_forwarded_tools() -> None: + payload = _to_anthropic_payload( + { + "model": "deepseek/deepseek-chat", + "input": "Reply without tools", + "tools": [ + { + "type": "function", + "name": "echo", + "parameters": { + "type": "object", + "properties": {"value": {"type": "string"}}, + }, + } + ], + "tool_choice": "none", + } + ) + + assert "tools" not in payload + assert "tool_choice" not in payload + + +def test_responses_namespace_tools_flatten_for_anthropic() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": "Use JS", + "tools": [ + { + "type": "namespace", + "name": "mcp__node_repl", + "description": "Node tools", + "tools": [ + { + "type": "function", + "name": "js", + "description": "Run JavaScript", + "parameters": { + "type": "object", + "properties": {"code": {"type": "string"}}, + "required": ["code"], + }, + } + ], + } + ], + "tool_choice": { + "type": "function", + "namespace": "mcp__node_repl", + "name": "js", + }, + } + ) + + assert payload["tools"] == [ + { + "name": "mcp__node_repl__js", + "description": "Run JavaScript", + "input_schema": { + "type": "object", + "properties": {"code": {"type": "string"}}, + "required": ["code"], + }, + } + ] + assert payload["tool_choice"] == {"type": "tool", "name": "mcp__node_repl__js"} + + +def test_responses_namespaced_tool_choice_type_tool_flattens_for_anthropic() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": "Use JS", + "tools": [ + { + "type": "namespace", + "name": "mcp__node_repl", + "tools": [ + { + "type": "function", + "name": "js", + "parameters": {"type": "object", "properties": {}}, + } + ], + } + ], + "tool_choice": { + "type": "tool", + "namespace": "mcp__node_repl", + "name": "js", + }, + } + ) + + assert payload["tool_choice"] == {"type": "tool", "name": "mcp__node_repl__js"} + + +def test_responses_custom_tool_converts_to_anthropic_string_tool() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": "Use apply_patch", + "tools": [ + { + "type": "custom", + "name": "apply_patch", + "description": "Apply a repo patch", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: /.+/", + }, + } + ], + "tool_choice": {"type": "custom", "name": "apply_patch"}, + } + ) + + assert payload["tools"] == [ + { + "name": "apply_patch", + "description": ( + "Apply a repo patch\n\n" + "Custom tool input format: grammar (lark): start: /.+/" + ), + "input_schema": { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "Free-form input for the custom tool.", + } + }, + "required": ["input"], + }, + } + ] + assert payload["tool_choice"] == {"type": "tool", "name": "apply_patch"} + + +def test_responses_namespaced_custom_tool_flattens_for_anthropic() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": "Use shell", + "tools": [ + { + "type": "namespace", + "name": "mcp__shell", + "tools": [ + { + "type": "custom", + "name": "exec", + "description": "Run shell text", + "format": {"type": "text"}, + } + ], + } + ], + "tool_choice": { + "type": "custom", + "namespace": "mcp__shell", + "custom": {"name": "exec"}, + }, + } + ) + + assert payload["tools"][0]["name"] == "mcp__shell__exec" + assert payload["tools"][0]["description"] == ( + "Run shell text\n\nCustom tool input format: unconstrained text." + ) + assert payload["tool_choice"] == {"type": "tool", "name": "mcp__shell__exec"} + + +def test_responses_passive_codex_built_in_tools_are_ignored() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": "Hello", + "tools": [ + {"type": "web_search", "external_web_access": True}, + {"type": "image_generation", "output_format": "png"}, + {"type": "tool_search"}, + { + "type": "function", + "name": "echo", + "parameters": {"type": "object", "properties": {}}, + }, + ], + } + ) + + assert payload["tools"] == [ + {"name": "echo", "input_schema": {"type": "object", "properties": {}}} + ] + + +def test_responses_namespaced_prior_function_call_flattens_tool_use_name() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "namespace": "mcp__node_repl", + "name": "js", + "arguments": '{"code":"1+1"}', + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "2", + }, + ], + } + ) + + assert payload["messages"][0]["content"][0]["name"] == "mcp__node_repl__js" + + +def test_responses_prior_custom_tool_call_flattens_tool_use_name() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": [ + { + "type": "custom_tool_call", + "call_id": "call_1", + "namespace": "mcp__shell", + "name": "exec", + "input": "printf FCC", + }, + { + "type": "custom_tool_call_output", + "call_id": "call_1", + "output": "FCC", + }, + ], + } + ) + + assert payload["messages"] == [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_1", + "name": "mcp__shell__exec", + "input": {"input": "printf FCC"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_1", + "content": "FCC", + } + ], + }, + ] + + +def test_responses_groups_consecutive_prior_tool_calls() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "echo", + "arguments": '{"value":"FCC"}', + }, + { + "type": "custom_tool_call", + "call_id": "call_2", + "name": "apply_patch", + "input": "*** Begin Patch", + }, + ], + } + ) + + assert payload["messages"] == [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_1", + "name": "echo", + "input": {"value": "FCC"}, + }, + { + "type": "tool_use", + "id": "call_2", + "name": "apply_patch", + "input": {"input": "*** Begin Patch"}, + }, + ], + } + ] + + +def test_responses_groups_consecutive_prior_tool_outputs() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "echo", + "arguments": '{"value":"FCC"}', + }, + { + "type": "function_call", + "call_id": "call_2", + "name": "echo", + "arguments": '{"value":"Codex"}', + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "FCC", + }, + { + "type": "function_call_output", + "call_id": "call_2", + "output": "Codex", + }, + ], + } + ) + + assert len(payload["messages"]) == 2 + assert payload["messages"][0]["role"] == "assistant" + assert len(payload["messages"][0]["content"]) == 2 + assert payload["messages"][1] == { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_1", + "content": "FCC", + }, + { + "type": "tool_result", + "tool_use_id": "call_2", + "content": "Codex", + }, + ], + } + + +def test_responses_reasoning_between_tool_call_and_output_attaches_to_tool_message() -> ( + None +): + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "echo", + "arguments": '{"value":"FCC"}', + }, + { + "type": "reasoning", + "content": [{"type": "reasoning_text", "text": "Need the result."}], + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "FCC", + }, + ], + } + ) + + assert payload["messages"][0]["reasoning_content"] == "Need the result." + assert payload["messages"][0]["content"][0]["id"] == "call_1" + assert payload["messages"][1]["content"][0]["tool_use_id"] == "call_1" + + +def test_responses_empty_reasoning_attaches_to_prior_tool_call() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "echo", + "arguments": '{"value":"FCC"}', + }, + { + "type": "reasoning", + "content": [{"type": "reasoning_text", "text": ""}], + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": "FCC", + }, + ], + } + ) + + assert payload["messages"][0]["reasoning_content"] == "" + + +def test_responses_unsupported_tool_type_is_clear() -> None: + with pytest.raises(_CONVERSION_ERROR, match="Unsupported Responses tool type"): + _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": "Hello", + "tools": [{"type": "web_search_preview"}], + } + ) + + +def test_responses_malformed_prior_function_call_is_quarantined() -> None: + payload = _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": [ + {"role": "user", "content": "hello"}, + { + "type": "function_call", + "call_id": "call_bad", + "name": "echo", + "arguments": "{", + }, + { + "type": "function_call_output", + "call_id": "call_bad", + "output": "stale output", + }, + { + "type": "function_call", + "call_id": "call_good", + "name": "echo", + "arguments": '{"value":"ok"}', + }, + { + "type": "function_call_output", + "call_id": "call_good", + "output": "ok", + }, + {"role": "user", "content": "continue"}, + ], + } + ) + + assert payload["messages"] == [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_good", + "name": "echo", + "input": {"value": "ok"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_good", + "content": "ok", + } + ], + }, + {"role": "user", "content": "continue"}, + ] + + +def test_responses_malformed_only_function_call_still_has_no_routable_message() -> None: + with pytest.raises(_CONVERSION_ERROR, match="must contain a message"): + _to_anthropic_payload( + { + "model": "nvidia_nim/test-model", + "input": [ + { + "type": "function_call", + "call_id": "call_bad", + "name": "echo", + "arguments": "{", + }, + { + "type": "function_call_output", + "call_id": "call_bad", + "output": "stale output", + }, + ], + } + ) diff --git a/tests/core/openai_responses/test_openai_responses_models.py b/tests/core/openai_responses/test_openai_responses_models.py new file mode 100644 index 0000000..354555e --- /dev/null +++ b/tests/core/openai_responses/test_openai_responses_models.py @@ -0,0 +1,94 @@ +"""Wire-compatibility tests for core-owned OpenAI Responses models.""" + +import pytest +from pydantic import ValidationError + +from free_claude_code.core.openai_responses import OpenAIResponsesRequest + + +def test_responses_request_preserves_defaults_and_unknown_extensions() -> None: + request = OpenAIResponsesRequest.model_validate( + { + "model": "provider/model", + "input": "hello", + "provider_extension": {"enabled": True}, + } + ) + + assert request.stream is True + assert request.model_extra == {"provider_extension": {"enabled": True}} + assert request.model_dump(mode="json", exclude_none=True) == { + "model": "provider/model", + "input": "hello", + "stream": True, + "provider_extension": {"enabled": True}, + } + + +@pytest.mark.parametrize(("stream", "expected"), [(False, False), (None, None)]) +def test_responses_request_preserves_explicit_stream_values( + stream: bool | None, + expected: bool | None, +) -> None: + request = OpenAIResponsesRequest.model_validate( + {"model": "provider/model", "input": "hello", "stream": stream} + ) + + assert request.stream is expected + + +def test_responses_request_keeps_permissive_nested_protocol_shapes() -> None: + payload = { + "model": "provider/model", + "input": [ + {"role": "user", "content": [{"type": "input_text", "text": "hi"}]}, + { + "type": "function_call", + "call_id": "call_1", + "name": "lookup", + "arguments": "{}", + }, + ], + "tools": [ + { + "type": "namespace", + "name": "mcp__tools", + "tools": [{"type": "custom", "name": "apply_patch"}], + } + ], + "tool_choice": {"type": "custom", "name": "apply_patch"}, + "metadata": {"trace": ["a", 1]}, + "reasoning": {"effort": "high", "provider_hint": {"mode": "extended"}}, + } + + request = OpenAIResponsesRequest.model_validate(payload) + + dumped = request.model_dump(mode="json", exclude_none=True) + for field_name, value in payload.items(): + assert dumped[field_name] == value + + +def test_responses_request_preserves_existing_pydantic_coercion() -> None: + request = OpenAIResponsesRequest.model_validate( + { + "model": "provider/model", + "stream": "true", + "temperature": "0.2", + "max_output_tokens": "32", + } + ) + + assert request.stream is True + assert request.temperature == 0.2 + assert request.max_output_tokens == 32 + + +def test_responses_request_still_requires_model() -> None: + with pytest.raises(ValidationError): + OpenAIResponsesRequest.model_validate({"input": "hello"}) + + +def test_responses_request_keeps_openapi_component_name() -> None: + assert OpenAIResponsesRequest.model_json_schema()["title"] == ( + "OpenAIResponsesRequest" + ) diff --git a/tests/core/openai_responses/test_sse.py b/tests/core/openai_responses/test_sse.py new file mode 100644 index 0000000..ededbfe --- /dev/null +++ b/tests/core/openai_responses/test_sse.py @@ -0,0 +1,970 @@ +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import patch + +import pytest + +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.core.anthropic.streaming import format_sse_event +from free_claude_code.core.async_iterators import AsyncCloseable +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.core.openai_responses import ( + OpenAIResponsesAdapter, + OpenAIResponsesRequest, +) +from free_claude_code.core.openai_responses.anthropic_sse import ( + AnthropicSseEvent, + iter_sse_events, +) + +_ADAPTER = OpenAIResponsesAdapter() + + +class _CloseTrackingAsyncIterator: + def __init__( + self, + values: list[Any], + *, + iteration_error: Exception | None = None, + close_error: Exception | None = None, + ) -> None: + self._values = iter(values) + self._iteration_error = iteration_error + self._close_error = close_error + self.close_calls = 0 + + def __aiter__(self) -> _CloseTrackingAsyncIterator: + return self + + async def __anext__(self) -> Any: + try: + return next(self._values) + except StopIteration: + if self._iteration_error is not None: + error = self._iteration_error + self._iteration_error = None + raise error from None + raise StopAsyncIteration from None + + async def aclose(self) -> None: + self.close_calls += 1 + if self._close_error is not None: + raise self._close_error + + +def _responses_sse( + chunks: AsyncIterator[str], request: dict[str, Any] +) -> AsyncIterator[str]: + return _ADAPTER.iter_sse_from_anthropic( + chunks, + OpenAIResponsesRequest.model_validate(request), + ) + + +@pytest.mark.asyncio +async def test_anthropic_sse_parser_closes_source_after_normal_completion() -> None: + source = _CloseTrackingAsyncIterator( + [format_sse_event("message_start", {"type": "message_start"})] + ) + + events = [event async for event in iter_sse_events(source)] + + assert [event.event for event in events] == ["message_start"] + assert source.close_calls == 1 + + +@pytest.mark.asyncio +async def test_anthropic_sse_parser_closes_source_on_early_close() -> None: + source = _CloseTrackingAsyncIterator( + [ + format_sse_event("message_start", {"type": "message_start"}), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + ) + events = iter_sse_events(source) + + assert (await anext(events)).event == "message_start" + assert isinstance(events, AsyncCloseable) + await events.aclose() + + assert source.close_calls == 1 + + +@pytest.mark.asyncio +async def test_anthropic_sse_parser_preserves_source_failure() -> None: + source_failure = RuntimeError("source failed") + source = _CloseTrackingAsyncIterator( + [], + iteration_error=source_failure, + close_error=RuntimeError("close failed"), + ) + + with pytest.raises(RuntimeError) as exc_info: + [event async for event in iter_sse_events(source)] + + assert exc_info.value is source_failure + assert source.close_calls == 1 + + +@pytest.mark.asyncio +async def test_responses_transform_closes_direct_event_source_on_early_close() -> None: + events = _CloseTrackingAsyncIterator( + [ + AnthropicSseEvent( + event="message_start", + data={"type": "message_start", "message": {}}, + ), + AnthropicSseEvent( + event="message_stop", + data={"type": "message_stop"}, + ), + ] + ) + with patch( + "free_claude_code.core.openai_responses.stream.iter_sse_events", + return_value=events, + ): + stream = _responses_sse( + _aiter([]), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + assert parse_sse_text(await anext(stream))[0].event == "response.created" + assert isinstance(stream, AsyncCloseable) + await stream.aclose() + + assert events.close_calls == 1 + + +@pytest.mark.asyncio +async def test_responses_transform_preserves_direct_source_failure() -> None: + source_failure = RuntimeError("event source failed") + events = _CloseTrackingAsyncIterator( + [], + iteration_error=source_failure, + close_error=RuntimeError("event close failed"), + ) + with ( + patch( + "free_claude_code.core.openai_responses.stream.iter_sse_events", + return_value=events, + ), + pytest.raises(RuntimeError) as exc_info, + ): + [ + chunk + async for chunk in _responses_sse( + _aiter([]), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + ] + + assert exc_info.value is source_failure + assert events.close_calls == 1 + + +@pytest.mark.asyncio +async def test_anthropic_text_stream_converts_to_responses_sse() -> None: + text = await _collect_sse( + _responses_sse( + _aiter(_anthropic_text_stream("Hello Codex")), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + ) + + events = parse_sse_text(text) + event_names = [event.event for event in events] + assert event_names[:3] == [ + "response.created", + "response.output_item.added", + "response.content_part.added", + ] + assert "response.output_text.delta" in event_names + assert events[-1].event == "response.completed" + completed = events[-1].data["response"] + assert completed["output"][0]["content"][0]["text"] == "Hello Codex" + assert completed["parallel_tool_calls"] is True + assert completed["tool_choice"] == "auto" + + +@pytest.mark.asyncio +async def test_response_payload_preserves_explicit_request_options() -> None: + response = await _completed_response_from_sse( + _aiter(_anthropic_text_stream("done")), + { + "model": "nvidia_nim/test-model", + "parallel_tool_calls": False, + "tool_choice": "none", + "temperature": 0.0, + "top_p": 0.0, + "max_output_tokens": 0, + }, + ) + + assert response["parallel_tool_calls"] is False + assert response["tool_choice"] == "none" + assert response["temperature"] == 0.0 + assert response["top_p"] == 0.0 + assert response["max_output_tokens"] == 0 + + +@pytest.mark.asyncio +async def test_response_payload_maps_explicit_null_options_to_wire_defaults() -> None: + response = await _completed_response_from_sse( + _aiter(_anthropic_text_stream("done")), + { + "model": "nvidia_nim/test-model", + "parallel_tool_calls": None, + "tool_choice": None, + }, + ) + + assert response["parallel_tool_calls"] is True + assert response["tool_choice"] == "auto" + + +@pytest.mark.asyncio +async def test_anthropic_tool_stream_converts_to_function_call_item() -> None: + text = await _collect_sse( + _responses_sse( + _aiter(_anthropic_tool_stream()), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + ) + + events = parse_sse_text(text) + names = [event.event for event in events] + assert "response.function_call_arguments.delta" in names + assert "response.function_call_arguments.done" in names + completed = events[-1].data["response"] + function_call = completed["output"][0] + assert function_call["type"] == "function_call" + assert function_call["call_id"] == "toolu_1" + assert function_call["name"] == "echo" + assert function_call["arguments"] == '{"value":"FCC"}' + + +@pytest.mark.asyncio +async def test_anthropic_function_tool_arguments_are_normalized() -> None: + response = await _completed_response_from_sse( + _aiter(_anthropic_tool_stream(partial_json='{ "value" : "FCC" }')), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + + assert response["output"][0]["arguments"] == '{"value":"FCC"}' + + +@pytest.mark.asyncio +async def test_anthropic_malformed_function_tool_arguments_fail_response() -> None: + text = await _collect_sse( + _responses_sse( + _aiter(_anthropic_tool_stream(partial_json='{"value":"FCC" "bad"}')), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + ) + + events = parse_sse_text(text) + assert events[-1].event == "response.failed" + assert "response.function_call_arguments.done" not in [ + event.event for event in events + ] + assert "response.output_item.done" not in [event.event for event in events] + failed = events[-1].data["response"] + assert failed["status"] == "failed" + assert failed["output"] == [] + assert failed["error"]["type"] == "api_error" + assert "replay-unsafe Responses output" in failed["error"]["message"] + + +@pytest.mark.asyncio +async def test_anthropic_malformed_function_tool_arguments_fail_on_eof() -> None: + stream = _anthropic_tool_stream( + partial_json='{"value":"FCC" "bad"}', + include_block_stop=False, + ) + text = await _collect_sse( + _responses_sse( + _aiter(stream[:-1]), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + ) + + events = parse_sse_text(text) + assert events[-1].event == "response.failed" + assert events[-1].data["response"]["output"] == [] + + +@pytest.mark.asyncio +async def test_provider_failure_outranks_provisional_incomplete_function_call() -> None: + failure = ExecutionFailure( + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="provider is busy\n\nRequest ID: req_failure_precedence", + retryable=True, + ) + incomplete_tool = _anthropic_tool_stream(partial_json='{"value":')[:4] + + text = await _collect_sse( + _responses_sse( + _aiter_then_raise(incomplete_tool, failure), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + ) + + events = parse_sse_text(text) + assert [event.event for event in events].count("response.failed") == 1 + assert events[-1].event == "response.failed" + failed = events[-1].data["response"] + assert failed["id"] == events[0].data["response"]["id"] + assert failed["error"] == { + "message": "provider is busy\n\nRequest ID: req_failure_precedence", + "type": "rate_limit_error", + "param": None, + "code": None, + } + + +@pytest.mark.asyncio +async def test_post_start_failure_observer_runs_before_terminal_failure_event() -> None: + failure = RuntimeError("socket closed") + timeline: list[tuple[str, object]] = [] + + def observe_terminal_failure(exc: BaseException) -> None: + timeline.append(("observed", exc)) + + stream = _ADAPTER.iter_sse_from_anthropic( + _aiter_then_raise(_anthropic_text_stream("partial")[:3], failure), + OpenAIResponsesRequest.model_validate( + {"model": "nvidia_nim/test-model", "stream": True} + ), + on_post_start_terminal_failure=observe_terminal_failure, + ) + parts: list[str] = [] + async for chunk in stream: + parts.append(chunk) + event = parse_sse_text(chunk) + assert len(event) == 1 + timeline.append(("emitted", event[0].event)) + + events = parse_sse_text("".join(parts)) + assert timeline.count(("observed", failure)) == 1 + assert timeline.index(("observed", failure)) < timeline.index( + ("emitted", "response.failed") + ) + assert events[-1].data["response"]["error"]["message"] == "socket closed" + + +@pytest.mark.asyncio +async def test_unrelated_post_start_exception_group_remains_unexpected() -> None: + grouped = ExceptionGroup("stream failed", [RuntimeError("socket closed")]) + observed: list[BaseException] = [] + stream = _ADAPTER.iter_sse_from_anthropic( + _aiter_then_raise(_anthropic_text_stream("partial")[:3], grouped), + OpenAIResponsesRequest.model_validate( + {"model": "nvidia_nim/test-model", "stream": True} + ), + on_post_start_terminal_failure=observed.append, + ) + + events = parse_sse_text(await _collect_sse(stream)) + + assert observed == [grouped] + assert events[-1].event == "response.failed" + assert events[-1].data["response"]["error"]["type"] == "api_error" + + +@pytest.mark.asyncio +async def test_namespaced_anthropic_tool_stream_restores_responses_namespace() -> None: + text = await _collect_sse( + _responses_sse( + _aiter(_anthropic_tool_stream(tool_name="mcp__node_repl__js")), + { + "model": "nvidia_nim/test-model", + "stream": True, + "tools": [ + { + "type": "namespace", + "name": "mcp__node_repl", + "tools": [ + { + "type": "function", + "name": "js", + "parameters": {"type": "object", "properties": {}}, + } + ], + } + ], + }, + ) + ) + + events = parse_sse_text(text) + completed = events[-1].data["response"] + function_call = completed["output"][0] + assert function_call["type"] == "function_call" + assert function_call["namespace"] == "mcp__node_repl" + assert function_call["name"] == "js" + + +@pytest.mark.asyncio +async def test_anthropic_custom_tool_stream_converts_to_custom_tool_call() -> None: + text = await _collect_sse( + _responses_sse( + _aiter( + _anthropic_tool_stream( + tool_name="apply_patch", + partial_json='{"input":"*** Begin Patch"}', + ) + ), + { + "model": "nvidia_nim/test-model", + "stream": True, + "tools": [ + { + "type": "custom", + "name": "apply_patch", + "format": {"type": "text"}, + } + ], + }, + ) + ) + + events = parse_sse_text(text) + names = [event.event for event in events] + assert "response.custom_tool_call_input.delta" in names + assert "response.custom_tool_call_input.done" in names + assert "response.function_call_arguments.delta" not in names + completed = events[-1].data["response"] + custom_call = completed["output"][0] + assert custom_call["type"] == "custom_tool_call" + assert custom_call["call_id"] == "toolu_1" + assert custom_call["name"] == "apply_patch" + assert custom_call["input"] == "*** Begin Patch" + + +@pytest.mark.asyncio +async def test_custom_tool_input_remains_free_form_when_not_json() -> None: + response = await _completed_response_from_sse( + _aiter( + _anthropic_tool_stream( + tool_name="apply_patch", + partial_json="*** Begin Patch", + ) + ), + { + "model": "nvidia_nim/test-model", + "stream": True, + "tools": [{"type": "custom", "name": "apply_patch"}], + }, + ) + + custom_call = response["output"][0] + assert custom_call["type"] == "custom_tool_call" + assert custom_call["input"] == "*** Begin Patch" + + +@pytest.mark.asyncio +async def test_anthropic_error_stream_converts_to_response_failed_event() -> None: + text = await _collect_sse( + _responses_sse( + _aiter( + [ + format_sse_event( + "error", + { + "type": "error", + "error": { + "type": "api_error", + "message": "upstream failed", + }, + }, + ) + ] + ), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + ) + + events = parse_sse_text(text) + assert events[0].event == "response.created" + assert events[1].event == "response.failed" + failed = events[1].data["response"] + assert failed["status"] == "failed" + assert failed["error"]["message"] == "upstream failed" + + +@pytest.mark.asyncio +async def test_split_usage_deltas_are_accumulated() -> None: + response = await _completed_response_from_sse( + _aiter( + [ + *_anthropic_text_stream("usage")[:-2], + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": {"input_tokens": 11}, + }, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {}, + "usage": {"output_tokens": 7}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + ), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + + assert response["usage"] == { + "input_tokens": 11, + "output_tokens": 7, + "total_tokens": 18, + } + assert "stop_reason" not in response + + +@pytest.mark.asyncio +async def test_reasoning_stream_reports_reasoning_usage_detail() -> None: + response = await _completed_response_from_sse( + _aiter(_anthropic_reasoning_stream("inspect the code before answering")), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + + usage = response["usage"] + assert usage["input_tokens"] == 3 + assert usage["output_tokens"] == 20 + assert usage["total_tokens"] == 23 + assert usage["output_tokens_details"]["reasoning_tokens"] > 0 + + +@pytest.mark.asyncio +async def test_reasoning_usage_detail_is_capped_at_output_tokens() -> None: + response = await _completed_response_from_sse( + _aiter( + _anthropic_reasoning_stream( + "this reasoning text is intentionally long enough to exceed one token", + output_tokens=1, + ) + ), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + + assert response["usage"]["output_tokens"] == 1 + assert response["usage"]["output_tokens_details"]["reasoning_tokens"] == 1 + + +@pytest.mark.asyncio +async def test_reasoning_usage_detail_omits_zero_capped_count() -> None: + response = await _completed_response_from_sse( + _aiter( + _anthropic_reasoning_stream( + "reasoning text exists without reported output tokens", + output_tokens=None, + ) + ), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + + assert response["usage"] == { + "input_tokens": 3, + "output_tokens": 0, + "total_tokens": 3, + } + + +@pytest.mark.asyncio +async def test_text_only_usage_omits_reasoning_usage_detail() -> None: + response = await _completed_response_from_sse( + _aiter(_anthropic_text_stream("plain text only")), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + + assert response["usage"] == { + "input_tokens": 3, + "output_tokens": 4, + "total_tokens": 7, + } + + +@pytest.mark.parametrize( + ("request_payload", "tool_name", "partial_json", "expected_type", "expected_field"), + [ + ( + {"model": "nvidia_nim/test-model", "stream": True}, + "echo", + '{"value":"FCC"}', + "function_call", + ("arguments", '{"value":"FCC"}'), + ), + ( + { + "model": "nvidia_nim/test-model", + "stream": True, + "tools": [{"type": "custom", "name": "apply_patch"}], + }, + "apply_patch", + '{"input":"*** Begin Patch"}', + "custom_tool_call", + ("input", "*** Begin Patch"), + ), + ], +) +@pytest.mark.asyncio +async def test_pending_tool_blocks_flush_on_message_stop_and_eof( + request_payload: dict[str, object], + tool_name: str, + partial_json: str, + expected_type: str, + expected_field: tuple[str, str], +) -> None: + stream = _anthropic_tool_stream( + tool_name=tool_name, partial_json=partial_json, include_block_stop=False + ) + message_stop_response = await _completed_response_from_sse( + _aiter(stream), request_payload + ) + eof_response = await _completed_response_from_sse( + _aiter(stream[:-1]), request_payload + ) + + for response in (message_stop_response, eof_response): + call = response["output"][0] + assert call["type"] == expected_type + assert call[expected_field[0]] == expected_field[1] + + +@pytest.mark.asyncio +async def test_overlapping_text_and_tool_blocks_keep_reserved_output_indexes() -> None: + text = await _collect_sse( + _responses_sse( + _aiter(_overlapping_text_tool_stream()), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + ) + + events = parse_sse_text(text) + item_events = [ + (event.event, event.data["output_index"]) + for event in events + if event.event in {"response.output_item.added", "response.output_item.done"} + ] + assert item_events == [ + ("response.output_item.added", 0), + ("response.output_item.added", 1), + ("response.output_item.done", 1), + ("response.output_item.done", 0), + ] + completed = events[-1].data["response"] + assert [item["type"] for item in completed["output"]] == [ + "message", + "function_call", + ] + assert completed["output"][0]["content"][0]["text"] == "text" + assert completed["output"][1]["arguments"] == '{"value":"FCC"}' + + +@pytest.mark.asyncio +async def test_overlapping_text_blocks_do_not_merge_content_by_index() -> None: + response = await _completed_response_from_sse( + _aiter(_overlapping_text_stream()), + {"model": "nvidia_nim/test-model", "stream": True}, + ) + + assert [item["content"][0]["text"] for item in response["output"]] == [ + "A1-A2", + "B1-B2", + ] + + +async def _collect_sse(chunks: AsyncIterator[str]) -> str: + parts = [chunk async for chunk in chunks] + return "".join(parts) + + +async def _completed_response_from_sse( + chunks: AsyncIterator[str], + request: dict[str, object], +) -> dict[str, Any]: + text = await _collect_sse(_responses_sse(chunks, request)) + events = parse_sse_text(text) + assert events[-1].event == "response.completed" + return events[-1].data["response"] + + +async def _aiter(chunks: list[str]) -> AsyncIterator[str]: + for chunk in chunks: + yield chunk + + +async def _aiter_then_raise( + chunks: list[str], failure: BaseException +) -> AsyncIterator[str]: + for chunk in chunks: + yield chunk + raise failure + + +def _anthropic_text_stream(text: str) -> list[str]: + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": text}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"input_tokens": 3, "output_tokens": 4}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + + +def _anthropic_tool_stream( + tool_name: str = "echo", + partial_json: str = '{"value":"FCC"}', + *, + include_block_stop: bool = True, +) -> list[str]: + chunks = [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "toolu_1", + "name": tool_name, + "input": {}, + }, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "input_json_delta", + "partial_json": partial_json, + }, + }, + ), + ] + if include_block_stop: + chunks.append( + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ) + ) + chunks.extend( + [ + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "tool_use", "stop_sequence": None}, + "usage": {"input_tokens": 3, "output_tokens": 4}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + ) + return chunks + + +def _anthropic_reasoning_stream( + reasoning: str, + *, + output_tokens: int | None = 20, +) -> list[str]: + usage = {"input_tokens": 3} + if output_tokens is not None: + usage["output_tokens"] = output_tokens + + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": reasoning}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": usage, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + + +def _overlapping_text_tool_stream() -> list[str]: + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "text"}, + }, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_use", + "id": "toolu_1", + "name": "echo", + "input": {}, + }, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 1, + "delta": { + "type": "input_json_delta", + "partial_json": '{"value":"FCC"}', + }, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 1}, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": "tool_use", "stop_sequence": None}, + "usage": {"input_tokens": 3, "output_tokens": 4}, + }, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] + + +def _overlapping_text_stream() -> list[str]: + return [ + format_sse_event("message_start", {"type": "message_start", "message": {}}), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "A1-"}, + }, + ), + format_sse_event( + "content_block_start", + { + "type": "content_block_start", + "index": 1, + "content_block": {"type": "text", "text": ""}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "text_delta", "text": "B1-"}, + }, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "A2"}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 0}, + ), + format_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "text_delta", "text": "B2"}, + }, + ), + format_sse_event( + "content_block_stop", + {"type": "content_block_stop", "index": 1}, + ), + format_sse_event("message_stop", {"type": "message_stop"}), + ] diff --git a/tests/core/test_async_iterators.py b/tests/core/test_async_iterators.py new file mode 100644 index 0000000..2a1d825 --- /dev/null +++ b/tests/core/test_async_iterators.py @@ -0,0 +1,54 @@ +"""Async iterator lifecycle helper contracts.""" + +import asyncio + +import pytest + +from free_claude_code.core.async_iterators import ( + AsyncCloseable, + try_close_async_iterator, +) + + +class _Closeable: + def __init__(self, error: BaseException | None = None) -> None: + self._error = error + self.close_calls = 0 + + async def aclose(self) -> None: + self.close_calls += 1 + if self._error is not None: + raise self._error + + +@pytest.mark.asyncio +async def test_try_close_async_iterator_closes_supported_value_once() -> None: + value = _Closeable() + + assert isinstance(value, AsyncCloseable) + assert await try_close_async_iterator(value) is None + assert value.close_calls == 1 + + +@pytest.mark.asyncio +async def test_try_close_async_iterator_ignores_non_closeable_value() -> None: + assert await try_close_async_iterator(object()) is None + + +@pytest.mark.asyncio +async def test_try_close_async_iterator_returns_ordinary_close_failure() -> None: + failure = RuntimeError("close failed") + value = _Closeable(failure) + + assert await try_close_async_iterator(value) is failure + assert value.close_calls == 1 + + +@pytest.mark.asyncio +async def test_try_close_async_iterator_propagates_cancellation() -> None: + value = _Closeable(asyncio.CancelledError()) + + with pytest.raises(asyncio.CancelledError): + await try_close_async_iterator(value) + + assert value.close_calls == 1 diff --git a/tests/core/test_diagnostics.py b/tests/core/test_diagnostics.py new file mode 100644 index 0000000..5654f20 --- /dev/null +++ b/tests/core/test_diagnostics.py @@ -0,0 +1,152 @@ +"""Protocol-neutral diagnostic redaction and detail contracts.""" + +from httpx import ConnectError, HTTPStatusError, Request, Response + +from free_claude_code.core.diagnostics import ( + ERROR_DETAIL_DISPLAY_CAP_BYTES, + UpstreamErrorDetail, + attach_upstream_error_body, + exception_cause_types, + extract_upstream_error_detail, + format_execution_failure_message, + format_user_error_preview, + redact_sensitive_error_text, + safe_exception_message, +) +from free_claude_code.core.failures import ExecutionFailure, FailureKind + + +def test_redaction_preserves_context_and_covers_recognizable_credentials() -> None: + sanitized = redact_sensitive_error_text( + '{"authorization":"Bearer AUTH_TOKEN","api_key":"sk-live-secret-key",' + '"client_secret":"CLIENT_SECRET"} raw=nvapi-standalone-secret ' + "token=PLAIN_TOKEN" + ) + + assert sanitized == ( + '{"authorization":"","api_key":"",' + '"client_secret":""} raw= token=' + ) + + +def test_safe_exception_message_is_detailed_redacted_and_non_empty() -> None: + assert ( + safe_exception_message( + RuntimeError("gateway failed api_key=SECRET useful detail") + ) + == "gateway failed api_key= useful detail" + ) + assert safe_exception_message(RuntimeError()) == ( + "Provider request failed unexpectedly." + ) + assert format_user_error_preview(ValueError("x" * 500), max_len=20) == "x" * 20 + + +def test_extract_upstream_error_detail_compacts_json_and_redacts_secrets() -> None: + response = Response( + status_code=400, + request=Request("POST", "https://provider.test/v1/messages"), + json={ + "error": { + "type": "BadRequest", + "message": "bad field api_key=SECRET authorization: Bearer TOKEN", + } + }, + ) + error = HTTPStatusError( + "Bad Request", + request=response.request, + response=response, + ) + + detail = extract_upstream_error_detail(error) + + assert isinstance(detail, UpstreamErrorDetail) + assert detail.status_code == 400 + assert detail.body_text == ( + '{"error":{"type":"BadRequest","message":' + '"bad field api_key= authorization: "}}' + ) + assert detail.exception_text == "Bad Request" + assert detail.cause_chain_text is None + assert detail.category_hint == "BadRequest" + assert not detail.body_truncated + assert "SECRET" not in repr(detail) + assert "TOKEN" not in repr(detail) + + +def test_attached_upstream_body_is_capped_after_redaction() -> None: + assert ERROR_DETAIL_DISPLAY_CAP_BYTES == 16_384 + response = Response( + status_code=500, + request=Request("POST", "https://provider.test/v1/messages"), + content=b"", + ) + error = HTTPStatusError( + "Server Error", + request=response.request, + response=response, + ) + attach_upstream_error_body( + error, + "token=SECRET " + "x" * ERROR_DETAIL_DISPLAY_CAP_BYTES, + ) + + detail = extract_upstream_error_detail(error) + + assert detail.body_text is not None + assert detail.body_truncated + assert "SECRET" not in detail.body_text + assert "token=" in detail.body_text + assert f"truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes" in detail.body_text + + +def test_cause_chain_is_redacted_capped_and_has_safe_type_metadata() -> None: + request = Request("POST", "https://provider.test/v1/messages") + error = RuntimeError("provider connection failed") + error.__cause__ = ConnectError( + "connect failed authorization: Bearer SECRET token=ALSO_SECRET " + + "x" * ERROR_DETAIL_DISPLAY_CAP_BYTES, + request=request, + ) + + detail = extract_upstream_error_detail(error) + + assert exception_cause_types(error) == ("ConnectError",) + assert detail.cause_chain_text is not None + assert "ConnectError: connect failed authorization: " in ( + detail.cause_chain_text + ) + assert "SECRET" not in detail.cause_chain_text + assert f"truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes" in ( + detail.cause_chain_text + ) + + +def test_execution_failure_format_uses_semantic_category_and_request_id() -> None: + failure = ExecutionFailure( + kind=FailureKind.INVALID_REQUEST, + status_code=400, + message="Invalid request sent to provider.", + retryable=False, + ) + detail = UpstreamErrorDetail( + status_code=400, + body_text='{"error":{"message":"bad field token="}}', + exception_text="Bad Request", + category_hint=None, + ) + + message = format_execution_failure_message( + failure, + detail, + upstream_name="ACME", + request_id="req_diagnostic", + ) + + assert "Upstream provider ACME returned HTTP 400." in message + assert "Category: invalid_request" in message + assert "Mapped message: Invalid request sent to provider." in message + assert '{"error":{"message":"bad field token="}}' in message + assert "Request ID: req_diagnostic" in message + assert "invalid_request_error" not in message diff --git a/tests/core/test_failure_protocol_mapping.py b/tests/core/test_failure_protocol_mapping.py new file mode 100644 index 0000000..dbf7032 --- /dev/null +++ b/tests/core/test_failure_protocol_mapping.py @@ -0,0 +1,49 @@ +"""Canonical execution failures and their protocol-owned wire mappings.""" + +import pytest + +from free_claude_code.core.anthropic.errors import ( + anthropic_error_type_for_failure, + anthropic_failure_payload, +) +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.core.openai_responses.errors import ( + openai_error_type_for_failure, + openai_failure_payload, +) + + +@pytest.mark.parametrize( + ("kind", "anthropic_type", "openai_type"), + [ + (FailureKind.INVALID_REQUEST, "invalid_request_error", "invalid_request_error"), + (FailureKind.AUTHENTICATION, "authentication_error", "authentication_error"), + (FailureKind.PERMISSION, "permission_error", "permission_error"), + (FailureKind.RATE_LIMIT, "rate_limit_error", "rate_limit_error"), + (FailureKind.OVERLOADED, "overloaded_error", "overloaded_error"), + # Existing finalized transport timeouts are exposed as api_error; the + # semantic kind becomes more precise without changing that wire contract. + (FailureKind.TIMEOUT, "api_error", "api_error"), + (FailureKind.UPSTREAM, "api_error", "api_error"), + (FailureKind.UNAVAILABLE, "api_error", "api_error"), + ], +) +def test_each_protocol_owns_failure_kind_to_wire_type_mapping( + kind: FailureKind, + anthropic_type: str, + openai_type: str, +) -> None: + assert anthropic_error_type_for_failure(kind) == anthropic_type + assert openai_error_type_for_failure(kind) == openai_type + + +def test_finalized_status_502_timeout_keeps_existing_api_error_wire_type() -> None: + failure = ExecutionFailure( + kind=FailureKind.TIMEOUT, + status_code=502, + message="Provider request timed out.", + retryable=True, + ) + + assert anthropic_failure_payload(failure)["error"]["type"] == "api_error" + assert openai_failure_payload(failure)["error"]["type"] == "api_error" diff --git a/tests/core/test_failures.py b/tests/core/test_failures.py new file mode 100644 index 0000000..1a2672b --- /dev/null +++ b/tests/core/test_failures.py @@ -0,0 +1,112 @@ +"""Canonical, protocol-neutral execution failure contracts.""" + +from dataclasses import FrozenInstanceError, fields, is_dataclass + +import pytest + +from free_claude_code.core.failures import ( + ExecutionFailure, + FailureKind, + find_execution_failure, +) + + +def test_failure_kind_has_only_protocol_neutral_semantics() -> None: + assert tuple(FailureKind) == ( + FailureKind.INVALID_REQUEST, + FailureKind.AUTHENTICATION, + FailureKind.PERMISSION, + FailureKind.RATE_LIMIT, + FailureKind.OVERLOADED, + FailureKind.TIMEOUT, + FailureKind.UPSTREAM, + FailureKind.UNAVAILABLE, + ) + assert tuple(kind.value for kind in FailureKind) == ( + "invalid_request", + "authentication", + "permission", + "rate_limit", + "overloaded", + "timeout", + "upstream", + "unavailable", + ) + + +def test_execution_failure_is_the_direct_frozen_slotted_exception() -> None: + failure = ExecutionFailure( + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="Provider rate limit reached.", + retryable=True, + ) + + assert is_dataclass(failure) + assert tuple(field.name for field in fields(failure)) == ( + "kind", + "status_code", + "message", + "retryable", + ) + assert ExecutionFailure.__slots__ == ( + "kind", + "status_code", + "message", + "retryable", + ) + assert str(failure) == "Provider rate limit reached." + assert failure.args == ("Provider rate limit reached.",) + + with pytest.raises(ExecutionFailure) as raised: + raise failure + + assert raised.value is failure + with pytest.raises(FrozenInstanceError): + failure.status_code = 500 + + +def test_execution_failure_uses_exception_identity_not_value_equality() -> None: + first = ExecutionFailure( + kind=FailureKind.UPSTREAM, + status_code=500, + message="same", + retryable=True, + ) + second = ExecutionFailure( + kind=FailureKind.UPSTREAM, + status_code=500, + message="same", + retryable=True, + ) + + assert first is not second + assert first != second + + +def test_find_execution_failure_recurses_through_nested_groups() -> None: + failure = ExecutionFailure( + kind=FailureKind.RATE_LIMIT, + status_code=429, + message="provider is busy", + retryable=True, + ) + grouped = ExceptionGroup( + "stream and cleanup failed", + [ + RuntimeError("cleanup failed"), + ExceptionGroup("provider failed", [failure]), + ], + ) + + assert find_execution_failure(failure) is failure + assert find_execution_failure(grouped) is failure + + +def test_find_execution_failure_leaves_unrelated_groups_unclassified() -> None: + grouped = BaseExceptionGroup( + "unrelated failures", + [RuntimeError("socket closed"), KeyboardInterrupt()], + ) + + assert find_execution_failure(grouped) is None diff --git a/tests/core/test_protocol_model_ownership.py b/tests/core/test_protocol_model_ownership.py new file mode 100644 index 0000000..4b5921b --- /dev/null +++ b/tests/core/test_protocol_model_ownership.py @@ -0,0 +1,76 @@ +"""Protocol models live with the protocol logic that consumes them.""" + +import subprocess +import sys + +from free_claude_code.core.anthropic import ( + MessagesRequest as PublicMessagesRequest, +) +from free_claude_code.core.anthropic import ( + MessagesResponse, + TokenCountResponse, +) +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.openai_responses import ( + OpenAIResponsesRequest as PublicOpenAIResponsesRequest, +) +from free_claude_code.core.openai_responses.models import OpenAIResponsesRequest + + +def test_anthropic_request_model_is_core_owned_and_permissive() -> None: + request = MessagesRequest.model_validate( + { + "model": "provider-model", + "messages": [{"role": "user", "content": "hello"}], + "provider_extension": {"enabled": True}, + } + ) + + assert MessagesRequest.__module__ == "free_claude_code.core.anthropic.models" + assert PublicMessagesRequest is MessagesRequest + assert request.model_extra == {"provider_extension": {"enabled": True}} + + +def test_responses_request_model_is_core_owned_and_permissive() -> None: + request = OpenAIResponsesRequest.model_validate( + { + "model": "provider-model", + "input": "hello", + "provider_extension": {"enabled": True}, + } + ) + + assert ( + OpenAIResponsesRequest.__module__ + == "free_claude_code.core.openai_responses.models" + ) + assert PublicOpenAIResponsesRequest is OpenAIResponsesRequest + assert request.model_extra == {"provider_extension": {"enabled": True}} + + +def test_anthropic_response_models_are_protocol_owned() -> None: + assert MessagesResponse.__module__ == "free_claude_code.core.anthropic.models" + assert TokenCountResponse.__module__ == "free_claude_code.core.anthropic.models" + + +def test_protocol_facades_are_import_order_independent() -> None: + import_orders = ( + ( + "free_claude_code.core.anthropic", + "free_claude_code.core.openai_responses", + ), + ( + "free_claude_code.core.openai_responses", + "free_claude_code.core.anthropic", + ), + ) + + for modules in import_orders: + script = "; ".join(f"import {module}" for module in modules) + completed = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + check=False, + text=True, + ) + assert completed.returncode == 0, completed.stderr diff --git a/tests/core/test_strict_sliding_window.py b/tests/core/test_strict_sliding_window.py new file mode 100644 index 0000000..7622ba2 --- /dev/null +++ b/tests/core/test_strict_sliding_window.py @@ -0,0 +1,76 @@ +"""Direct tests for :class:`core.rate_limit.StrictSlidingWindowLimiter`.""" + +import asyncio +import time + +import pytest + +import free_claude_code.core.rate_limit as rate_limit_module +from free_claude_code.core.rate_limit import StrictSlidingWindowLimiter + + +@pytest.mark.asyncio +async def test_strict_window_allows_burst_then_blocks(): + lim = StrictSlidingWindowLimiter(rate_limit=2, rate_window=0.2) + await lim.acquire() + await lim.acquire() + start = time.monotonic() + await lim.acquire() + assert time.monotonic() - start >= 0.15 + + +@pytest.mark.asyncio +async def test_strict_window_async_context_manager(): + lim = StrictSlidingWindowLimiter(rate_limit=1, rate_window=0.15) + + async def run(): + async with lim: + pass + + await run() + start = time.monotonic() + await run() + assert time.monotonic() - start >= 0.1 + + +@pytest.mark.asyncio +async def test_rejected_conditional_acquisition_does_not_consume_capacity(): + lim = StrictSlidingWindowLimiter(rate_limit=1, rate_window=60) + + assert await lim.acquire_if(lambda: False) is False + + await asyncio.wait_for(lim.acquire(), timeout=0.1) + + +@pytest.mark.asyncio +async def test_conditional_acquisition_records_predicate_commit_time( + monkeypatch: pytest.MonkeyPatch, +) -> None: + now = 0.0 + sleep_delays: list[float] = [] + lim = StrictSlidingWindowLimiter(rate_limit=1, rate_window=10) + + def advance_during_condition() -> bool: + nonlocal now + now = 100.0 + return True + + async def advance_during_sleep(delay: float) -> None: + nonlocal now + sleep_delays.append(delay) + now += delay + + monkeypatch.setattr(rate_limit_module.time, "monotonic", lambda: now) + monkeypatch.setattr(rate_limit_module.asyncio, "sleep", advance_during_sleep) + + assert await lim.acquire_if(advance_during_condition) is True + await lim.acquire() + + assert sleep_delays == [10.0] + + +def test_strict_window_rejects_invalid_config(): + with pytest.raises(ValueError): + StrictSlidingWindowLimiter(rate_limit=0, rate_window=1.0) + with pytest.raises(ValueError): + StrictSlidingWindowLimiter(rate_limit=1, rate_window=0.0) diff --git a/tests/core/test_trace.py b/tests/core/test_trace.py new file mode 100644 index 0000000..60c0614 --- /dev/null +++ b/tests/core/test_trace.py @@ -0,0 +1,174 @@ +"""Structured TRACE logging assertions.""" + +import json +from pathlib import Path + +import pytest +from loguru import logger + +from free_claude_code.config.logging_config import configure_logging +from free_claude_code.core.trace import ( + TRACE_PAYLOAD_BINDING, + trace_event, + traced_async_stream, +) + + +class _CloseTrackingIterator: + def __init__( + self, + chunks: list[str], + *, + iteration_error: Exception | None = None, + close_error: Exception | None = None, + ) -> None: + self._chunks = iter(chunks) + self._iteration_error = iteration_error + self._close_error = close_error + self.close_calls = 0 + + def __aiter__(self) -> _CloseTrackingIterator: + return self + + async def __anext__(self) -> str: + try: + return next(self._chunks) + except StopIteration: + if self._iteration_error is not None: + error = self._iteration_error + self._iteration_error = None + raise error from None + raise StopAsyncIteration from None + + async def aclose(self) -> None: + self.close_calls += 1 + if self._close_error is not None: + raise self._close_error + + +def _json_log_rows(log_file: str) -> list[dict]: + logger.complete() + text = Path(log_file).read_text(encoding="utf-8").strip() + if not text: + return [] + return [json.loads(line) for line in text.split("\n")] + + +def test_trace_payload_merged_into_json_line(tmp_path) -> None: + log_file = str(tmp_path / "t.log") + configure_logging(log_file, force=True) + trace_event(stage="s", event="e.v1", source="unit", hello="world", n=42) + row = _json_log_rows(log_file)[-1] + assert row["trace"] is True + assert row["stage"] == "s" + assert row["event"] == "e.v1" + assert row["source"] == "unit" + assert row["hello"] == "world" + assert row["n"] == 42 + assert TRACE_PAYLOAD_BINDING == "trace_payload" + + +def test_sanitize_masks_nested_api_key_strings() -> None: + """Credential-shaped keys redact without touching normal message text.""" + from free_claude_code.core.trace import sanitize_trace_value + + out = sanitize_trace_value( + {"outer": {"api_key": "secret", "text": "visible"}}, + ) + assert out["outer"]["api_key"] == "" + assert out["outer"]["text"] == "visible" + + +@pytest.mark.asyncio +async def test_traced_async_stream_logs_completion(tmp_path) -> None: + log_file = str(tmp_path / "complete.log") + configure_logging(log_file, force=True) + + source = _CloseTrackingIterator(["hello", " world"]) + + chunks = [ + chunk + async for chunk in traced_async_stream( + source, + stage="egress", + source="unit", + complete_event="stream.completed", + interrupted_event="stream.interrupted", + extra={"request_id": "req_complete"}, + ) + ] + + assert chunks == ["hello", " world"] + assert source.close_calls == 1 + rows = _json_log_rows(log_file) + completed = [row for row in rows if row.get("event") == "stream.completed"] + assert len(completed) == 1 + assert completed[0]["request_id"] == "req_complete" + assert completed[0]["stream_chunks"] == 2 + assert completed[0]["outcome"] == "ok" + + +@pytest.mark.asyncio +async def test_traced_async_stream_logs_real_exception(tmp_path) -> None: + log_file = str(tmp_path / "error.log") + configure_logging(log_file, force=True) + + source = _CloseTrackingIterator( + ["before"], + iteration_error=RuntimeError("boom"), + close_error=RuntimeError("close boom"), + ) + + with pytest.raises(RuntimeError, match="boom"): + async for _chunk in traced_async_stream( + source, + stage="egress", + source="unit", + complete_event="stream.completed", + interrupted_event="stream.interrupted", + extra={"request_id": "req_error"}, + ): + pass + + assert source.close_calls == 1 + + rows = _json_log_rows(log_file) + interrupted = [row for row in rows if row.get("event") == "stream.interrupted"] + assert len(interrupted) == 1 + assert interrupted[0]["request_id"] == "req_error" + assert interrupted[0]["stream_chunks"] == 1 + assert interrupted[0]["outcome"] == "error" + assert interrupted[0]["exc_type"] == "RuntimeError" + close_failed = [ + row for row in rows if row.get("event") == "stream.input.close_failed" + ] + assert len(close_failed) == 1 + assert close_failed[0]["owner"] == "traced_async_stream" + assert close_failed[0]["close_exc_type"] == "RuntimeError" + assert close_failed[0]["preserved_exc_type"] == "RuntimeError" + + +@pytest.mark.asyncio +async def test_traced_async_stream_closes_quietly_on_generator_exit(tmp_path) -> None: + log_file = str(tmp_path / "generator_exit.log") + configure_logging(log_file, force=True) + + source = _CloseTrackingIterator(["first", "second"]) + + stream = traced_async_stream( + source, + stage="egress", + source="unit", + complete_event="stream.completed", + interrupted_event="stream.interrupted", + extra={"request_id": "req_closed"}, + ) + + assert await anext(stream) == "first" + await stream.aclose() + + assert source.close_calls == 1 + rows = _json_log_rows(log_file) + events = {row.get("event") for row in rows} + assert "stream.completed" not in events + assert "stream.interrupted" not in events diff --git a/tests/core/test_version.py b/tests/core/test_version.py new file mode 100644 index 0000000..084409b --- /dev/null +++ b/tests/core/test_version.py @@ -0,0 +1,42 @@ +import tomllib +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as distribution_version +from pathlib import Path + +import pytest + +import free_claude_code.core.version as version_module + + +def test_package_version_uses_installed_distribution_metadata() -> None: + assert version_module.package_version() == distribution_version("free-claude-code") + + +def test_package_version_has_explicit_uninstalled_source_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def missing(_distribution_name: str) -> str: + raise PackageNotFoundError("free-claude-code") + + monkeypatch.setattr(version_module, "distribution_version", missing) + + assert version_module.package_version() == "0+unknown" + + +def test_package_version_does_not_hide_invalid_installed_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def invalid(_distribution_name: str) -> str: + raise ValueError("invalid metadata") + + monkeypatch.setattr(version_module, "distribution_version", invalid) + + with pytest.raises(ValueError, match="invalid metadata"): + version_module.package_version() + + +def test_project_release_version_matches_installed_metadata() -> None: + repo_root = Path(__file__).resolve().parents[2] + pyproject = tomllib.loads((repo_root / "pyproject.toml").read_text("utf-8")) + + assert pyproject["project"]["version"] == distribution_version("free-claude-code") diff --git a/tests/messaging/test_discord_markdown.py b/tests/messaging/test_discord_markdown.py new file mode 100644 index 0000000..bb69efa --- /dev/null +++ b/tests/messaging/test_discord_markdown.py @@ -0,0 +1,203 @@ +"""Tests for messaging/rendering/discord_markdown.py.""" + +from free_claude_code.messaging.rendering.discord_markdown import ( + discord_bold, + discord_code_inline, + escape_discord, + escape_discord_code, + format_status, + format_status_discord, + render_markdown_to_discord, +) +from free_claude_code.messaging.rendering.markdown_tables import ( + _is_gfm_table_header_line, + normalize_gfm_tables, +) + + +class TestEscapeDiscord: + """Tests for escape_discord.""" + + def test_empty_string(self): + assert escape_discord("") == "" + + def test_plain_text_unchanged(self): + assert escape_discord("hello world") == "hello world" + + def test_special_chars_escaped(self): + for ch in "\\*_`~|>": + assert escape_discord(ch) == f"\\{ch}" + + def test_mixed_special_and_plain(self): + assert escape_discord("a*b_c") == "a\\*b\\_c" + + def test_unicode_preserved(self): + assert escape_discord("café 日本語") == "café 日本語" + + +class TestEscapeDiscordCode: + """Tests for escape_discord_code.""" + + def test_empty_string(self): + assert escape_discord_code("") == "" + + def test_backslash_escaped(self): + assert escape_discord_code("\\") == "\\\\" + + def test_backtick_escaped(self): + assert escape_discord_code("`") == "\\`" + + def test_both_escaped(self): + assert escape_discord_code("`\\") == "\\`\\\\" + + +class TestDiscordBold: + """Tests for discord_bold.""" + + def test_simple(self): + assert discord_bold("hello") == "**hello**" + + def test_escapes_inner(self): + assert discord_bold("a*b") == "**a\\*b**" + + +class TestDiscordCodeInline: + """Tests for discord_code_inline.""" + + def test_simple(self): + assert discord_code_inline("x") == "`x`" + + def test_escapes_backtick(self): + assert discord_code_inline("`") == "`\\``" + + +class TestFormatStatusDiscord: + """Tests for format_status_discord.""" + + def test_label_only(self): + assert format_status_discord("Running") == "**Running**" + + def test_label_with_suffix(self): + # Parentheses not in DISCORD_SPECIAL, so unchanged + assert ( + format_status_discord("Queued", "(position 2)") == "**Queued** (position 2)" + ) + + +class TestFormatStatus: + """Tests for format_status.""" + + def test_label_only(self): + assert format_status("🔄", "Running") == "🔄 **Running**" + + def test_label_with_suffix(self): + assert format_status("⏳", "Waiting", "5/10") == "⏳ **Waiting** 5/10" + + +class TestIsGfmTableHeaderLine: + """Tests for _is_gfm_table_header_line.""" + + def test_no_pipe_returns_false(self): + assert _is_gfm_table_header_line("hello world") is False + + def test_separator_only_returns_false(self): + assert _is_gfm_table_header_line("|---|") is False + assert _is_gfm_table_header_line("|:---|:---|") is False + + def test_valid_header(self): + assert _is_gfm_table_header_line("| A | B |") is True + assert _is_gfm_table_header_line("A | B") is True + + def test_single_column_returns_false(self): + assert _is_gfm_table_header_line("| A |") is False + + +class TestNormalizeGfmTables: + """Tests for _normalize_gfm_tables.""" + + def test_single_line_unchanged(self): + assert normalize_gfm_tables("hello") == "hello" + + def test_two_lines_no_table_unchanged(self): + assert normalize_gfm_tables("a\nb") == "a\nb" + + def test_table_gets_blank_line_before(self): + text = "para\n| A | B |\n|---|\n| 1 | 2 |" + result = normalize_gfm_tables(text) + assert "para" in result + assert "| A | B |" in result + + def test_table_inside_fence_unchanged(self): + text = "```\n| A | B |\n|---|\n```" + result = normalize_gfm_tables(text) + assert result == text + + +class TestRenderMarkdownToDiscord: + """Tests for render_markdown_to_discord.""" + + def test_empty_string(self): + assert render_markdown_to_discord("") == "" + + def test_plain_paragraph(self): + assert "hello" in render_markdown_to_discord("hello") + + def test_headings(self): + result = render_markdown_to_discord("# Title\n## Sub") + assert "Title" in result + assert "Sub" in result + + def test_bold_italic(self): + result = render_markdown_to_discord("**bold** *italic*") + assert "bold" in result + assert "italic" in result + + def test_strikethrough(self): + result = render_markdown_to_discord("~~strike~~") + assert "strike" in result + + def test_inline_code(self): + result = render_markdown_to_discord("use `code` here") + assert "`" in result + assert "code" in result + + def test_code_block(self): + result = render_markdown_to_discord("```\nprint(1)\n```") + assert "print(1)" in result + assert "```" in result + + def test_blockquote(self): + result = render_markdown_to_discord("> quote") + assert "quote" in result + + def test_bullet_list(self): + result = render_markdown_to_discord("- a\n- b") + assert "a" in result + assert "b" in result + + def test_ordered_list(self): + result = render_markdown_to_discord("1. first\n2. second") + assert "first" in result + assert "second" in result + + def test_link(self): + result = render_markdown_to_discord("[text](https://example.com)") + assert "text" in result + assert "https://example.com" in result + + def test_image_with_alt(self): + result = render_markdown_to_discord("![alt](https://img.png)") + assert "alt" in result + assert "https://img.png" in result + + def test_image_without_alt(self): + result = render_markdown_to_discord("![](https://img.png)") + assert "https://img.png" in result + + def test_gfm_table(self): + text = "| A | B |\n|---|---|\n| 1 | 2 |" + result = render_markdown_to_discord(text) + assert "A" in result + assert "B" in result + assert "1" in result + assert "2" in result diff --git a/tests/messaging/test_discord_platform.py b/tests/messaging/test_discord_platform.py new file mode 100644 index 0000000..17c41a7 --- /dev/null +++ b/tests/messaging/test_discord_platform.py @@ -0,0 +1,598 @@ +"""Tests for Discord platform adapter.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.messaging.platforms.discord import ( + DISCORD_AVAILABLE, + DiscordRuntime, + _get_discord, +) +from free_claude_code.messaging.platforms.discord_inbound import ( + discord_text_message_from_event, + parse_allowed_channels, +) +from free_claude_code.messaging.platforms.discord_io import truncate_discord_message + + +def _limiter_mock() -> MagicMock: + limiter = MagicMock() + limiter.start = MagicMock() + limiter.shutdown = AsyncMock() + return limiter + + +def _discord_runtime(*args, limiter=None, transcriber=None, **kwargs) -> DiscordRuntime: + return DiscordRuntime( + *args, + limiter=limiter or _limiter_mock(), + transcriber=transcriber, + **kwargs, + ) + + +class TestGetDiscord: + """Tests for _get_discord helper.""" + + def test_raises_when_discord_not_available(self): + import free_claude_code.messaging.platforms.discord as discord_mod + + with ( + patch.object(discord_mod, "DISCORD_AVAILABLE", False), + patch.object(discord_mod, "_discord_module", None), + pytest.raises(ImportError, match=r"discord\.py is required"), + ): + _get_discord() + + +class TestParseAllowedChannels: + """Tests for _parse_allowed_channels helper.""" + + def test_empty_string_returns_empty_set(self): + assert parse_allowed_channels("") == set() + assert parse_allowed_channels(None) == set() + + def test_whitespace_only_returns_empty_set(self): + assert parse_allowed_channels(" ") == set() + + def test_single_channel(self): + assert parse_allowed_channels("123456789") == {"123456789"} + + def test_comma_separated(self): + assert parse_allowed_channels("111,222,333") == {"111", "222", "333"} + + def test_strips_whitespace(self): + assert parse_allowed_channels(" 111 , 222 ") == {"111", "222"} + + def test_empty_parts_ignored(self): + assert parse_allowed_channels("111,,222,") == {"111", "222"} + + +class TestDiscordInbound: + def test_text_message_normalizes_accepted_event(self): + msg = MagicMock() + msg.author.id = 456 + msg.author.display_name = "User" + msg.content = "hello" + msg.channel.id = 123 + msg.id = 789 + msg.reference.message_id = 555 + + incoming = discord_text_message_from_event( + msg, + log_raw_messaging_content=False, + ) + + assert incoming.text == "hello" + assert incoming.chat_id == "123" + assert incoming.message_id == "789" + assert incoming.platform == "discord" + assert incoming.reply_to_message_id == "555" + + +@pytest.mark.skipif(not DISCORD_AVAILABLE, reason="discord.py not installed") +class TestDiscordRuntime: + """Tests for Discord runtime and messenger behavior.""" + + def test_init_with_token(self): + platform = _discord_runtime( + bot_token="test_token", + allowed_channel_ids="123,456", + ) + assert platform.bot_token == "test_token" + assert platform.allowed_channel_ids == {"123", "456"} + + def test_init_without_allowed_channels(self): + with patch.dict("os.environ", {"ALLOWED_DISCORD_CHANNELS": ""}, clear=False): + platform = _discord_runtime(bot_token="token", allowed_channel_ids="") + assert platform.allowed_channel_ids == set() + + def test_empty_allowed_channels_rejects_all_messages(self): + """When allowed_channel_ids is empty, no channels are allowed (secure default).""" + with patch.dict("os.environ", {"ALLOWED_DISCORD_CHANNELS": ""}, clear=False): + platform = _discord_runtime(bot_token="token", allowed_channel_ids="") + assert platform.allowed_channel_ids == set() + # Empty set means: not self.allowed_channel_ids is True -> reject + + def test_truncate_long_message(self): + long_text = "x" * 2500 + truncated = truncate_discord_message(long_text) + assert len(truncated) == 2000 + assert truncated.endswith("...") + + def test_truncate_short_message_unchanged(self): + short = "hello" + assert truncate_discord_message(short) == short + + def test_truncate_exactly_at_limit_unchanged(self): + exact = "x" * 2000 + assert truncate_discord_message(exact) == exact + + def test_truncate_one_over_limit_truncates(self): + over = "x" * 2001 + result = truncate_discord_message(over) + assert len(result) == 2000 + assert result.endswith("...") + + def test_truncate_empty_string(self): + assert truncate_discord_message("") == "" + + @pytest.mark.asyncio + async def test_send_message_returns_message_id(self): + platform = _discord_runtime(bot_token="token") + mock_msg = MagicMock() + mock_msg.id = 999 + mock_channel = AsyncMock() + mock_channel.send = AsyncMock(return_value=mock_msg) + platform._connected = True + with patch.object( + platform._client, "get_channel", MagicMock(return_value=mock_channel) + ): + msg_id = await platform.outbound.send_message("123", "Hello") + assert msg_id == "999" + + @pytest.mark.asyncio + async def test_edit_message(self): + platform = _discord_runtime(bot_token="token") + mock_msg = AsyncMock() + mock_channel = AsyncMock() + mock_channel.fetch_message = AsyncMock(return_value=mock_msg) + platform._connected = True + with patch.object( + platform._client, "get_channel", MagicMock(return_value=mock_channel) + ): + await platform.outbound.edit_message("123", "456", "Updated text") + mock_msg.edit.assert_called_once_with(content="Updated text") + + @pytest.mark.asyncio + async def test_send_message_channel_not_found_raises(self): + platform = _discord_runtime(bot_token="token") + platform._connected = True + with ( + patch.object(platform._client, "get_channel", MagicMock(return_value=None)), + pytest.raises(RuntimeError, match="Channel"), + ): + await platform.outbound.send_message("123", "Hello") + + @pytest.mark.asyncio + async def test_send_message_channel_no_send_raises(self): + platform = _discord_runtime(bot_token="token") + platform._connected = True + mock_channel = MagicMock(spec=[]) # No send attr + with ( + patch.object( + platform._client, "get_channel", MagicMock(return_value=mock_channel) + ), + pytest.raises(RuntimeError, match="Channel"), + ): + await platform.outbound.send_message("123", "Hello") + + @pytest.mark.asyncio + async def test_queue_send_message_uses_required_limiter(self): + platform = _discord_runtime(bot_token="token") + + async def enqueue(operation, dedup_key=None): + return await operation() + + platform._limiter.enqueue = AsyncMock(side_effect=enqueue) + platform._connected = True + mock_channel = AsyncMock() + mock_msg = MagicMock() + mock_msg.id = 42 + mock_channel.send = AsyncMock(return_value=mock_msg) + with patch.object( + platform._client, "get_channel", MagicMock(return_value=mock_channel) + ): + result = await platform.outbound.queue_send_message( + "123", "hi", fire_and_forget=False + ) + assert result == "42" + platform._limiter.enqueue.assert_awaited_once() + mock_channel.send.assert_awaited_once() + + @pytest.mark.asyncio + async def test_queue_edit_message_uses_required_limiter(self): + platform = _discord_runtime(bot_token="token") + + async def enqueue(operation, dedup_key=None): + return await operation() + + platform._limiter.enqueue = AsyncMock(side_effect=enqueue) + platform._connected = True + mock_msg = AsyncMock() + mock_channel = AsyncMock() + mock_channel.fetch_message = AsyncMock(return_value=mock_msg) + with patch.object( + platform._client, "get_channel", MagicMock(return_value=mock_channel) + ): + await platform.outbound.queue_edit_message( + "123", "456", "Updated", fire_and_forget=False + ) + platform._limiter.enqueue.assert_awaited_once() + mock_msg.edit.assert_called_once_with(content="Updated") + + @pytest.mark.asyncio + async def test_on_discord_message_bot_ignored(self): + platform = _discord_runtime(bot_token="token", allowed_channel_ids="123") + handler = AsyncMock() + platform.on_message(handler) + msg = MagicMock() + msg.author.bot = True + msg.content = "hello" + msg.channel.id = 123 + await platform._on_discord_message(msg) + handler.assert_not_called() + + @pytest.mark.asyncio + async def test_on_discord_message_empty_content_ignored(self): + platform = _discord_runtime(bot_token="token", allowed_channel_ids="123") + handler = AsyncMock() + platform.on_message(handler) + msg = MagicMock() + msg.author.bot = False + msg.content = "" + msg.channel.id = 123 + await platform._on_discord_message(msg) + handler.assert_not_called() + + @pytest.mark.asyncio + async def test_on_discord_message_channel_not_allowed_ignored(self): + platform = _discord_runtime(bot_token="token", allowed_channel_ids="123") + handler = AsyncMock() + platform.on_message(handler) + msg = MagicMock() + msg.author.bot = False + msg.content = "hello" + msg.channel.id = 999 + await platform._on_discord_message(msg) + handler.assert_not_called() + + @pytest.mark.asyncio + async def test_on_discord_message_valid_calls_handler(self): + platform = _discord_runtime(bot_token="token", allowed_channel_ids="123") + handler = AsyncMock() + platform.on_message(handler) + msg = MagicMock() + msg.author.bot = False + msg.author.id = 456 + msg.author.display_name = "User" + msg.content = "hello" + msg.channel.id = 123 + msg.id = 789 + msg.reference = None + await platform._on_discord_message(msg) + handler.assert_awaited_once() + call = handler.call_args[0][0] + assert call.text == "hello" + assert call.chat_id == "123" + assert call.message_id == "789" + assert call.platform == "discord" + + @pytest.mark.asyncio + async def test_send_message_with_reply_to(self): + platform = _discord_runtime(bot_token="token") + mock_msg = MagicMock() + mock_msg.id = 999 + mock_channel = AsyncMock() + mock_channel.send = AsyncMock(return_value=mock_msg) + platform._connected = True + with ( + patch.object( + platform._client, "get_channel", MagicMock(return_value=mock_channel) + ), + patch( + "free_claude_code.messaging.platforms.discord._get_discord" + ) as mock_get, + ): + mock_discord = MagicMock() + mock_get.return_value = mock_discord + platform.outbound._get_discord = mock_get + msg_id = await platform.outbound.send_message( + "123", "Hello", reply_to="456" + ) + assert msg_id == "999" + mock_channel.send.assert_awaited_once() + call_kw = mock_channel.send.call_args[1] + assert call_kw.get("reference") is not None + + @pytest.mark.asyncio + async def test_edit_message_not_found_returns_gracefully(self): + import discord as discord_pkg + + platform = _discord_runtime(bot_token="token") + mock_channel = AsyncMock() + mock_resp = MagicMock() + mock_resp.status = 404 + mock_channel.fetch_message = AsyncMock( + side_effect=discord_pkg.NotFound(mock_resp, "Not found") + ) + platform._connected = True + with patch.object( + platform._client, "get_channel", MagicMock(return_value=mock_channel) + ): + await platform.outbound.edit_message("123", "456", "Updated") + # Should not raise - NotFound is caught and we return + + @pytest.mark.asyncio + async def test_delete_message(self): + platform = _discord_runtime(bot_token="token") + mock_msg = AsyncMock() + mock_channel = AsyncMock() + mock_channel.fetch_message = AsyncMock(return_value=mock_msg) + platform._connected = True + with ( + patch.object( + platform._client, "get_channel", MagicMock(return_value=mock_channel) + ), + patch( + "free_claude_code.messaging.platforms.discord._get_discord" + ) as mock_get, + ): + mock_get.return_value = MagicMock() + platform.outbound._get_discord = mock_get + await platform.outbound.delete_message("123", "456") + mock_msg.delete.assert_awaited_once() + + @pytest.mark.asyncio + async def test_fire_and_forget_with_coroutine(self): + platform = _discord_runtime(bot_token="token") + completed = asyncio.Event() + + async def _task(): + completed.set() + + platform.outbound.fire_and_forget(_task()) + await completed.wait() + await platform.outbound.close() + assert platform.outbound._outbox._background_tasks == set() + + def test_on_message_registers_handler(self): + platform = _discord_runtime(bot_token="token") + handler = AsyncMock() + platform.on_message(handler) + assert platform._message_handler is handler + + @pytest.mark.asyncio + async def test_start_requires_token(self): + with patch.dict("os.environ", {"DISCORD_BOT_TOKEN": ""}, clear=False): + platform = _discord_runtime(bot_token="") + with pytest.raises(ValueError, match="DISCORD_BOT_TOKEN"): + await platform.start() + + @pytest.mark.asyncio + async def test_start_connects(self): + limiter = _limiter_mock() + platform = _discord_runtime(bot_token="token", limiter=limiter) + keep_running = asyncio.Event() + + async def _fake_start(_token): + platform._mark_connected() + await keep_running.wait() + + with patch.object( + platform._client, + "start", + new_callable=AsyncMock, + side_effect=_fake_start, + ): + await platform.start() + assert platform.is_connected is True + limiter.start.assert_called_once_with() + await platform.quiesce() + await platform.close() + + @pytest.mark.asyncio + async def test_client_failure_after_readiness_marks_runtime_disconnected(self): + limiter = _limiter_mock() + platform = _discord_runtime(bot_token="token", limiter=limiter) + fail_client = asyncio.Event() + + async def _fake_start(_token): + platform._mark_connected() + await fail_client.wait() + raise RuntimeError("connection lost") + + with patch.object( + platform._client, + "start", + new_callable=AsyncMock, + side_effect=_fake_start, + ): + await platform.start() + assert platform.is_connected is True + + fail_client.set() + assert platform._start_task is not None + result = await asyncio.wait_for( + asyncio.gather(platform._start_task, return_exceptions=True), + timeout=1.0, + ) + assert isinstance(result[0], RuntimeError) + await asyncio.sleep(0) + + assert platform.is_connected is False + await platform.quiesce() + await platform.close() + + @pytest.mark.asyncio + async def test_quiesce_when_client_already_closed_then_close_delivery(self): + limiter = _limiter_mock() + platform = _discord_runtime(bot_token="token", limiter=limiter) + platform._connected = True + with patch.object( + platform._client, "is_closed", new_callable=MagicMock, return_value=True + ): + await platform.quiesce() + assert platform.is_connected is False + limiter.shutdown.assert_not_awaited() + + await platform.close() + + limiter.shutdown.assert_awaited_once_with() + + @pytest.mark.asyncio + async def test_quiesce_closes_client_without_closing_delivery(self): + limiter = _limiter_mock() + platform = _discord_runtime(bot_token="token", limiter=limiter) + platform._connected = True + mock_close = AsyncMock() + with ( + patch.object( + platform._client, + "is_closed", + new_callable=MagicMock, + return_value=False, + ), + patch.object(platform._client, "close", mock_close), + ): + platform._start_task = None + await platform.quiesce() + mock_close.assert_awaited_once() + assert platform.is_connected is False + limiter.shutdown.assert_not_awaited() + + await platform.close() + + limiter.shutdown.assert_awaited_once_with() + + @pytest.mark.asyncio + async def test_quiesce_drains_start_task_when_client_close_fails(self): + limiter = _limiter_mock() + platform = _discord_runtime(bot_token="token", limiter=limiter) + platform._connected = True + started = asyncio.Event() + + async def pending_start() -> None: + started.set() + await asyncio.Event().wait() + + platform._start_task = asyncio.create_task(pending_start()) + await started.wait() + with ( + patch.object( + platform._client, + "is_closed", + new_callable=MagicMock, + return_value=False, + ), + patch.object( + platform._client, + "close", + new_callable=AsyncMock, + side_effect=RuntimeError("close failed"), + ), + pytest.raises(RuntimeError, match="close failed"), + ): + await platform.quiesce() + + assert platform._start_task is None + limiter.shutdown.assert_not_awaited() + assert platform.is_connected is False + + await platform.close() + + limiter.shutdown.assert_awaited_once_with() + + @pytest.mark.asyncio + async def test_start_propagates_client_failure_immediately(self): + limiter = _limiter_mock() + platform = _discord_runtime(bot_token="token", limiter=limiter) + + with ( + patch.object( + platform._client, + "start", + new_callable=AsyncMock, + side_effect=RuntimeError("invalid token"), + ), + pytest.raises(RuntimeError, match="invalid token"), + ): + await platform.start() + + assert platform._start_task is not None + assert platform._start_task.done() + await platform.quiesce() + await platform.close() + + @pytest.mark.asyncio + async def test_quiesce_waits_for_tracked_inbound_handler(self): + platform = _discord_runtime(bot_token="token") + platform._accepting_messages = True + entered = asyncio.Event() + release = asyncio.Event() + + async def handle(_message) -> None: + entered.set() + await release.wait() + + with ( + patch.object(platform, "_on_discord_message", side_effect=handle), + patch.object( + platform._client, + "is_closed", + new_callable=MagicMock, + return_value=True, + ), + ): + handler_task = asyncio.create_task( + platform._handle_client_message(MagicMock()) + ) + await entered.wait() + quiesce_task = asyncio.create_task(platform.quiesce()) + await asyncio.sleep(0) + + assert not quiesce_task.done() + release.set() + await handler_task + await quiesce_task + + assert platform._inbound_tasks == set() + await platform.close() + + @pytest.mark.asyncio + async def test_quiesce_preserves_caller_cancellation(self): + platform = _discord_runtime(bot_token="token") + entered = asyncio.Event() + + async def close_client() -> None: + entered.set() + await asyncio.Event().wait() + + with ( + patch.object( + platform._client, + "is_closed", + new_callable=MagicMock, + return_value=False, + ), + patch.object(platform._client, "close", side_effect=close_client), + ): + quiesce_task = asyncio.create_task(platform.quiesce()) + await entered.wait() + quiesce_task.cancel() + with pytest.raises(asyncio.CancelledError): + await quiesce_task + + await platform.close() diff --git a/tests/messaging/test_event_parser.py b/tests/messaging/test_event_parser.py new file mode 100644 index 0000000..4efd475 --- /dev/null +++ b/tests/messaging/test_event_parser.py @@ -0,0 +1,197 @@ +from free_claude_code.messaging.event_parser import parse_cli_event + + +def test_parse_cli_event_assistant_content(): + event = { + "type": "assistant", + "message": { + "content": [ + {"type": "thinking", "thinking": "Internal thought"}, + {"type": "text", "text": "Hello user"}, + ] + }, + } + results = parse_cli_event(event) + assert len(results) == 2 + assert results[0] == {"type": "thinking_chunk", "text": "Internal thought"} + assert results[1] == {"type": "text_chunk", "text": "Hello user"} + + +def test_parse_cli_event_assistant_tools(): + event = { + "type": "assistant", + "message": { + "content": [{"type": "tool_use", "name": "ls", "input": {"path": "."}}] + }, + } + results = parse_cli_event(event) + assert len(results) == 1 + assert results[0]["type"] == "tool_use" + assert results[0]["name"] == "ls" + assert results[0]["input"] == {"path": "."} + + +def test_parse_cli_event_assistant_subagent(): + event = { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "name": "Task", + "input": {"description": "Fix bug"}, + } + ] + }, + } + results = parse_cli_event(event) + assert len(results) == 1 + assert results[0]["type"] == "tool_use" + assert results[0]["name"] == "Task" + assert results[0]["input"] == {"description": "Fix bug"} + + +def test_parse_cli_event_content_block_delta(): + # Text delta + event_text = { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": " more"}, + } + results_text = parse_cli_event(event_text) + assert results_text == [{"type": "text_delta", "index": 0, "text": " more"}] + + # Thinking delta + event_think = { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "thinking_delta", "thinking": " more thought"}, + } + results_think = parse_cli_event(event_think) + assert results_think == [ + {"type": "thinking_delta", "index": 1, "text": " more thought"} + ] + + +def test_parse_cli_event_content_block_start(): + event = { + "type": "content_block_start", + "index": 2, + "content_block": { + "type": "tool_use", + "name": "Task", + "input": {"description": "deploy"}, + }, + } + results = parse_cli_event(event) + assert results == [ + { + "type": "tool_use_start", + "index": 2, + "id": "", + "name": "Task", + "input": {"description": "deploy"}, + } + ] + + +def test_parse_cli_event_error(): + event = {"type": "error", "error": {"message": "something failed"}} + results = parse_cli_event(event) + assert results == [{"type": "error", "message": "something failed"}] + + +def test_parse_cli_event_user_tool_result(): + event = { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "tool_use_id": "tool_1", + "content": "ok", + "is_error": False, + } + ] + }, + } + results = parse_cli_event(event) + assert results == [ + { + "type": "tool_result", + "tool_use_id": "tool_1", + "content": "ok", + "is_error": False, + } + ] + + +def test_parse_cli_event_exit_success(): + event = {"type": "exit", "code": 0} + results = parse_cli_event(event) + assert results == [{"type": "complete", "status": "success"}] + + +def test_parse_cli_event_exit_failure(): + event = {"type": "exit", "code": 1, "stderr": "fatal error"} + results = parse_cli_event(event) + assert results == [ + { + "type": "error", + "message": "fatal error", + "source": "exit", + "exit_code": 1, + } + ] + + +def test_parse_cli_event_invalid_input(): + assert parse_cli_event(None) == [] + assert parse_cli_event("not a dict") == [] + assert parse_cli_event({"type": "unknown"}) == [] + + +def test_parse_cli_event_system_ignored(): + assert parse_cli_event({"type": "system", "foo": "bar"}) == [] + + +def test_parse_cli_event_result_with_content_directly(): + event = {"type": "result", "content": [{"type": "text", "text": "hi"}]} + assert parse_cli_event(event) == [{"type": "text_chunk", "text": "hi"}] + + +def test_parse_cli_event_result_with_result_content_directly(): + event = {"type": "result", "result": {"content": [{"type": "text", "text": "hi"}]}} + assert parse_cli_event(event) == [{"type": "text_chunk", "text": "hi"}] + + +def test_parse_cli_event_content_block_unknown_type_skipped(): + """Content block with unknown type is skipped; known blocks still parsed.""" + event = { + "type": "assistant", + "message": { + "content": [ + {"type": "text", "text": "visible"}, + {"type": "unknown", "data": "ignored"}, + {"type": "thinking", "thinking": "thought"}, + ] + }, + } + results = parse_cli_event(event) + assert len(results) == 2 + assert results[0] == {"type": "text_chunk", "text": "visible"} + assert results[1] == {"type": "thinking_chunk", "text": "thought"} + + +def test_parse_cli_event_error_non_dict(): + """Error event with error as string (not dict) is handled.""" + event = {"type": "error", "error": "plain string error"} + results = parse_cli_event(event) + assert results == [{"type": "error", "message": "plain string error"}] + + +def test_parse_cli_event_exit_code_none(): + """Exit event with no code defaults to success.""" + event = {"type": "exit"} + results = parse_cli_event(event) + assert results == [{"type": "complete", "status": "success"}] diff --git a/tests/messaging/test_extract_text.py b/tests/messaging/test_extract_text.py new file mode 100644 index 0000000..a62ed2e --- /dev/null +++ b/tests/messaging/test_extract_text.py @@ -0,0 +1,109 @@ +"""Tests for extract_text_from_content helper functions.""" + +from unittest.mock import MagicMock + +import pytest + +from free_claude_code.core.anthropic import extract_text_from_content + + +class TestExtractTextFromContent: + """Tests for core.anthropic.extract_text_from_content.""" + + def test_string_content(self): + """Return string content as-is.""" + assert extract_text_from_content("hello world") == "hello world" + + def test_empty_string(self): + """Return empty string for empty string input.""" + assert extract_text_from_content("") == "" + + def test_list_single_block(self): + """Extract text from a single content block.""" + block = MagicMock() + block.text = "some text" + assert extract_text_from_content([block]) == "some text" + + def test_list_multiple_blocks(self): + """Concatenate text from multiple content blocks.""" + b1 = MagicMock() + b1.text = "hello " + b2 = MagicMock() + b2.text = "world" + assert extract_text_from_content([b1, b2]) == "hello world" + + def test_list_with_non_text_block(self): + """Skip blocks without text attribute.""" + b1 = MagicMock() + b1.text = "hello" + b2 = MagicMock(spec=[]) # No attributes + assert extract_text_from_content([b1, b2]) == "hello" + + def test_list_with_empty_text(self): + """Skip blocks with empty text.""" + b1 = MagicMock() + b1.text = "" + b2 = MagicMock() + b2.text = "world" + assert extract_text_from_content([b1, b2]) == "world" + + def test_list_with_none_text(self): + """Skip blocks with None text.""" + b1 = MagicMock() + b1.text = None + b2 = MagicMock() + b2.text = "world" + assert extract_text_from_content([b1, b2]) == "world" + + def test_empty_list(self): + """Return empty string for empty list.""" + assert extract_text_from_content([]) == "" + + def test_non_string_non_list(self): + """Return empty string for unexpected types.""" + assert extract_text_from_content(None) == "" + assert extract_text_from_content(42) == "" + + def test_list_with_non_string_text_attr(self): + """Skip blocks where text is not a string.""" + b1 = MagicMock() + b1.text = 123 # Not a string + b2 = MagicMock() + b2.text = "valid" + assert extract_text_from_content([b1, b2]) == "valid" + + +# --- Parametrized Edge Case Tests --- + + +def _make_block(text_val): + b = MagicMock() + b.text = text_val + return b + + +@pytest.mark.parametrize( + "content,expected", + [ + ("hello world", "hello world"), + ("", ""), + (None, ""), + (42, ""), + ([], ""), + (" ", " "), + ], + ids=["string", "empty_str", "none", "int", "empty_list", "whitespace_only"], +) +def test_extract_text_scalar_and_empty_parametrized(content, expected): + """Parametrized scalar and empty input handling.""" + assert extract_text_from_content(content) == expected + + +def test_extract_functions_whitespace_only(): + """extract_text_from_content handles whitespace-only string.""" + assert extract_text_from_content(" ") == " " + + +def test_extract_functions_unicode(): + """extract_text_from_content handles unicode content.""" + assert extract_text_from_content("日本語テスト") == "日本語テスト" diff --git a/tests/messaging/test_handler.py b/tests/messaging/test_handler.py new file mode 100644 index 0000000..4dac095 --- /dev/null +++ b/tests/messaging/test_handler.py @@ -0,0 +1,2499 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest + +from free_claude_code.messaging.command_context import ReplyClearResult, StopOutcome +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.platforms.ports import MessagingStartupNotice +from free_claude_code.messaging.session import SessionStore +from free_claude_code.messaging.trees import ( + CancellationReason, + CancellationResult, + CancellationUiOwner, + FailureResult, + MessageReferenceKind, + MessageState, + MessageSubtreeRemovalResult, + NodeClaim, + NodeUiTarget, + QueueEntry, + ReplyTarget, + TreeIdentity, + TreeQueueManager, + TreeSnapshot, +) +from free_claude_code.messaging.trees.transitions import CancellationEffect +from free_claude_code.messaging.voice import VoiceCancellationResult +from free_claude_code.messaging.workflow import MessagingWorkflow + +_SCOPE = MessageScope(platform="telegram", chat_id="chat_1") +_OTHER_SCOPE = MessageScope(platform="telegram", chat_id="other_chat") + + +def _stop_outcome( + cancelled_count: int, + *, + scopes: frozenset[MessageScope] = frozenset({_SCOPE}), + fallback_required: bool = False, +) -> StopOutcome: + return StopOutcome(cancelled_count, scopes, fallback_required) + + +def _startup_notice(chat_id: str = _SCOPE.chat_id) -> MessagingStartupNotice: + return MessagingStartupNotice(chat_id=chat_id, transport_label="Bot API") + + +async def _event_stream(events): + for event in events: + await asyncio.sleep(0) + yield event + + +def _claim( + node_id: str = "node_1", + *, + prompt: str = "hello", + parent_session_id: str | None = None, +) -> NodeClaim: + return NodeClaim( + identity=TreeIdentity(scope=_SCOPE, root_id="root_1"), + claim_id="claim_1", + node=NodeUiTarget( + scope=_SCOPE, + node_id=node_id, + status_message_id="status_1", + ), + prompt=prompt, + parent_session_id=parent_session_id, + ) + + +def _snapshot(root_id: str = "root_1") -> TreeSnapshot: + return TreeSnapshot(scope=_SCOPE, root_id=root_id, nodes={}) + + +def _session(events) -> MagicMock: + session = MagicMock() + session.start_task.return_value = _event_stream(events) + return session + + +async def _wait_for_idle(workflow: MessagingWorkflow) -> None: + for _ in range(200): + if workflow.tree_queue.task_count() == 0: + await asyncio.sleep(0) + return + await asyncio.sleep(0.01) + raise AssertionError("messaging workflow did not become idle") + + +@pytest.fixture +def handler(mock_platform, mock_cli_manager, mock_session_store): + default_session = _session([{"type": "exit", "code": 0}]) + mock_cli_manager.get_or_create_session.return_value = ( + default_session, + "session_1", + False, + ) + return MessagingWorkflow( + mock_platform, + mock_cli_manager, + mock_session_store, + platform_name="telegram", + voice_cancellation=mock_platform, + ) + + +@pytest.mark.asyncio +async def test_handle_message_turn_trace_always_includes_full_message_text( + mock_platform, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +): + text = "user-message-content-visible-in-trace" + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + mock_session_store, + ) + incoming = incoming_message_factory(text=text) + with ( + patch.object(workflow.turn_intake, "handle_message", new_callable=AsyncMock), + patch("free_claude_code.messaging.workflow.trace_event") as trace_mock, + ): + await workflow.handle_message(incoming) + + assert trace_mock.call_args.kwargs["event"] == "turn.received" + assert trace_mock.call_args.kwargs["message_text"] == text + + +@pytest.mark.asyncio +async def test_user_prompt_and_status_are_recorded_for_global_clear( + handler, + mock_session_store, + incoming_message_factory, +) -> None: + incoming = incoming_message_factory(text="keep me", message_id="user-prompt") + + await handler.handle_message(incoming) + await _wait_for_idle(handler) + + mock_session_store.record_message_id.assert_has_calls( + [ + call( + incoming.platform, + incoming.chat_id, + "user-prompt", + "in", + "prompt", + ), + call( + incoming.platform, + incoming.chat_id, + "msg_123", + "out", + "status", + ), + ] + ) + + +@pytest.mark.parametrize( + ("target", "expected"), + [ + (None, "Launching"), + ( + ReplyTarget( + node_id="parent", + reference_id="parent", + reference_kind=MessageReferenceKind.PROMPT, + queue_position=None, + ), + "Continuing", + ), + ( + ReplyTarget( + node_id="parent", + reference_id="status-parent", + reference_kind=MessageReferenceKind.STATUS, + queue_position=3, + ), + "position 3", + ), + ], +) +def test_initial_status_uses_immutable_reply_advice(handler, target, expected): + assert expected in handler.turn_intake._get_initial_status(target) + + +@pytest.mark.asyncio +async def test_global_stop_success_uses_existing_status_without_confirmation( + handler, mock_platform, incoming_message_factory +): + incoming = incoming_message_factory(text="/stop") + handler.stop_all_tasks = AsyncMock(return_value=_stop_outcome(5)) + + await handler.handle_message(incoming) + + handler.stop_all_tasks.assert_awaited_once() + mock_platform.queue_send_message.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status_feedback_scopes", + ( + frozenset({_OTHER_SCOPE}), + frozenset({_SCOPE, _OTHER_SCOPE}), + ), + ids=("remote-only", "mixed-local-and-remote"), +) +async def test_global_stop_reports_cross_chat_work( + handler, + mock_platform, + incoming_message_factory, + status_feedback_scopes: frozenset[MessageScope], +) -> None: + incoming = incoming_message_factory(text="/stop") + handler.stop_all_tasks = AsyncMock( + return_value=_stop_outcome(2, scopes=status_feedback_scopes) + ) + + await handler.handle_message(incoming) + + assert ( + "Cancelled 2 pending or active requests" + in mock_platform.queue_send_message.call_args.args[1] + ) + + +@pytest.mark.asyncio +async def test_global_stop_noop_reports_nothing_to_stop( + handler, mock_platform, incoming_message_factory +) -> None: + incoming = incoming_message_factory(text="/stop") + handler.stop_all_tasks = AsyncMock( + return_value=_stop_outcome(0, scopes=frozenset()) + ) + + await handler.handle_message(incoming) + + mock_platform.queue_send_message.assert_awaited_once_with( + incoming.chat_id, + "⏹ *Stopped\\.* Nothing to stop\\.", + fire_and_forget=False, + message_thread_id=None, + ) + + +@pytest.mark.asyncio +async def test_global_stop_without_status_target_sends_fallback_confirmation( + handler, mock_platform, incoming_message_factory +) -> None: + incoming = incoming_message_factory(text="/stop") + handler.stop_all_tasks = AsyncMock( + return_value=_stop_outcome( + 1, + scopes=frozenset(), + fallback_required=True, + ) + ) + + await handler.handle_message(incoming) + + assert ( + "Cancelled 1 pending or active request" + in mock_platform.queue_send_message.call_args.args[1] + ) + + +@pytest.mark.asyncio +async def test_failed_global_stop_never_reports_success( + handler, mock_platform, incoming_message_factory +) -> None: + incoming = incoming_message_factory(text="/stop") + handler.stop_all_tasks = AsyncMock( + side_effect=RuntimeError("Failed to stop 1 managed Claude session.") + ) + + with pytest.raises(RuntimeError, match="Failed to stop"): + await handler.handle_message(incoming) + + mock_platform.queue_send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reply_stop_resolves_and_stops_only_target( + handler, mock_platform, mock_cli_manager, incoming_message_factory +): + handler.stop_reply = AsyncMock(return_value=_stop_outcome(1)) + handler.stop_all_tasks = AsyncMock(return_value=_stop_outcome(999)) + incoming = incoming_message_factory( + text="/stop", + message_id="stop_msg", + reply_to_message_id="status_root", + ) + + await handler.handle_message(incoming) + + handler.stop_reply.assert_awaited_once_with(incoming.scope, "status_root") + handler.stop_all_tasks.assert_not_awaited() + mock_cli_manager.stop_all.assert_not_awaited() + mock_platform.queue_send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reply_stop_unknown_does_not_stop_all( + handler, mock_platform, mock_cli_manager, incoming_message_factory +): + handler.stop_reply = AsyncMock(return_value=_stop_outcome(0, scopes=frozenset())) + handler.stop_all_tasks = AsyncMock(return_value=_stop_outcome(5)) + incoming = incoming_message_factory( + text="/stop", + message_id="stop_msg", + reply_to_message_id="unknown_msg", + ) + + await handler.handle_message(incoming) + + handler.stop_reply.assert_awaited_once_with(incoming.scope, "unknown_msg") + handler.stop_all_tasks.assert_not_awaited() + mock_cli_manager.stop_all.assert_not_awaited() + assert ( + "Nothing to stop for that message" + in mock_platform.queue_send_message.call_args.args[1] + ) + + +@pytest.mark.asyncio +async def test_reply_stop_cancels_voice_when_no_tree_was_admitted( + handler, + mock_platform, + incoming_message_factory, +) -> None: + mock_platform.cancel_pending_voice.return_value = VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id="voice_status", + delete_message_ids=frozenset({"voice", "voice_status"}), + ) + incoming = incoming_message_factory( + text="/stop", + message_id="stop_msg", + reply_to_message_id="voice", + ) + + await handler.handle_message(incoming) + await asyncio.sleep(0) + + mock_platform.queue_send_message.assert_not_awaited() + assert mock_platform.queue_edit_message.call_args.args[1] == "voice_status" + assert "Stopped" in mock_platform.queue_edit_message.call_args.args[2] + + +@pytest.mark.asyncio +async def test_reply_stop_without_voice_status_sends_fallback_confirmation( + handler, + mock_platform, + incoming_message_factory, +) -> None: + mock_platform.cancel_pending_voice.return_value = VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id=None, + delete_message_ids=frozenset({"voice"}), + ) + incoming = incoming_message_factory( + text="/stop", + message_id="stop_msg", + reply_to_message_id="voice", + ) + + await handler.handle_message(incoming) + + assert "Cancelled 1 request" in mock_platform.queue_send_message.call_args.args[1] + mock_platform.queue_edit_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reply_stop_resolves_tree_after_joining_voice_handoff( + handler, + mock_platform, + incoming_message_factory, +) -> None: + events: list[str] = [] + + async def cancel_voice(*_args) -> VoiceCancellationResult: + events.append("voice") + return VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id="voice_status", + delete_message_ids=frozenset({"voice", "voice_status"}), + ) + + async def resolve_tree(*_args) -> str: + events.append("tree") + return "voice" + + mock_platform.cancel_pending_voice.side_effect = cancel_voice + cancellation = CancellationResult( + effects=( + CancellationEffect( + node=NodeUiTarget( + scope=_SCOPE, + node_id="voice", + status_message_id="voice_status", + ), + ui_owner=CancellationUiOwner.WORKFLOW, + ), + ) + ) + incoming = incoming_message_factory( + text="/stop", + message_id="stop_msg", + reply_to_message_id="voice", + ) + + with ( + patch.object( + handler.tree_queue, + "resolve_node_id", + side_effect=resolve_tree, + ), + patch.object( + handler.tree_queue, + "cancel_node", + new_callable=AsyncMock, + return_value=cancellation, + ) as cancel_node, + ): + await handler.handle_message(incoming) + + cancel_node.assert_awaited_once_with( + incoming.scope, + "voice", + reason=CancellationReason.STOP, + ) + assert events == ["voice", "tree"] + mock_platform.queue_send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reply_stop_port_failure_prevents_tree_transition( + handler, + mock_platform, +) -> None: + mock_platform.cancel_pending_voice.side_effect = RuntimeError("voice port failed") + with ( + patch.object( + handler.tree_queue, + "resolve_node_id", + new_callable=AsyncMock, + ) as resolve, + pytest.raises(RuntimeError, match="voice port failed"), + ): + await handler.stop_reply(_SCOPE, "voice") + + resolve.assert_not_awaited() + mock_platform.queue_edit_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reply_stop_renders_voice_before_resolve_failure( + handler, + mock_platform, +) -> None: + mock_platform.cancel_pending_voice.return_value = VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id="voice_status", + delete_message_ids=frozenset({"voice", "voice_status"}), + ) + with ( + patch.object( + handler.tree_queue, + "resolve_node_id", + AsyncMock(side_effect=RuntimeError("resolve failed")), + ), + pytest.raises(RuntimeError, match="resolve failed"), + ): + await handler.stop_reply(_SCOPE, "voice") + await asyncio.sleep(0) + + mock_platform.queue_edit_message.assert_awaited_once() + assert mock_platform.queue_edit_message.call_args.args[1] == "voice_status" + + +@pytest.mark.asyncio +async def test_cancelled_reply_stop_finishes_after_voice_join_and_lock_wait( + handler, + mock_platform, +) -> None: + voice_returned = asyncio.Event() + + async def cancel_voice(*_args) -> VoiceCancellationResult: + voice_returned.set() + return VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id="voice_status", + delete_message_ids=frozenset({"voice", "voice_status"}), + ) + + mock_platform.cancel_pending_voice.side_effect = cancel_voice + resolve = AsyncMock(return_value=None) + await handler._state_lock.acquire() + try: + with patch.object(handler.tree_queue, "resolve_node_id", resolve): + stop_task = asyncio.create_task(handler.stop_reply(_SCOPE, "voice")) + await voice_returned.wait() + await asyncio.sleep(0) + stop_task.cancel() + stop_task.cancel() + await asyncio.sleep(0) + + assert not stop_task.done() + handler._state_lock.release() + with pytest.raises(asyncio.CancelledError): + await stop_task + finally: + if handler._state_lock.locked(): + handler._state_lock.release() + await asyncio.sleep(0) + + assert stop_task.cancelling() == 2 + resolve.assert_awaited_once_with(_SCOPE, "voice") + mock_platform.queue_edit_message.assert_awaited_once() + assert mock_platform.queue_edit_message.call_args.args[1] == "voice_status" + + +@pytest.mark.asyncio +async def test_cancelled_reply_stop_preserves_later_operation_failure( + handler, + mock_platform, +) -> None: + voice_returned = asyncio.Event() + failure = RuntimeError("resolve failed after cancellation") + + async def cancel_voice(*_args) -> None: + voice_returned.set() + + mock_platform.cancel_pending_voice.side_effect = cancel_voice + resolve = AsyncMock(side_effect=failure) + await handler._state_lock.acquire() + try: + with patch.object(handler.tree_queue, "resolve_node_id", resolve): + stop_task = asyncio.create_task(handler.stop_reply(_SCOPE, "voice")) + await voice_returned.wait() + await asyncio.sleep(0) + stop_task.cancel() + await asyncio.sleep(0) + + assert not stop_task.done() + handler._state_lock.release() + with pytest.raises(RuntimeError) as raised: + await stop_task + finally: + if handler._state_lock.locked(): + handler._state_lock.release() + + assert raised.value is failure + assert stop_task.cancelling() == 1 + resolve.assert_awaited_once_with(_SCOPE, "voice") + + +@pytest.mark.asyncio +async def test_stats_command_reports_cli_and_tree_counts( + handler, mock_platform, mock_cli_manager, incoming_message_factory +): + mock_cli_manager.get_stats.return_value = {"active_sessions": 2} + + await handler.handle_message(incoming_message_factory(text="/stats")) + + text = mock_platform.queue_send_message.call_args.args[1] + assert "Active CLI: 2" in text + assert "Message Trees: 0" in text + assert mock_platform.queue_send_message.call_args.kwargs["fire_and_forget"] is False + + +@pytest.mark.asyncio +async def test_status_echo_is_filtered( + handler, mock_platform, incoming_message_factory +): + await handler.handle_message(incoming_message_factory(text="⏳ Thinking...")) + + mock_platform.queue_send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_new_turn_uses_public_admission_and_persists_exact_snapshot( + handler, + mock_platform, + mock_session_store, + incoming_message_factory, +): + incoming = incoming_message_factory(text="hello", message_id="node_1") + mock_platform.queue_send_message.return_value = "status_123" + + await handler.handle_message(incoming) + await _wait_for_idle(handler) + + assert "Launching" in mock_platform.queue_send_message.call_args.args[1] + assert mock_session_store.save_tree_snapshot.call_count >= 2 + view = await handler.tree_queue.get_node(incoming.scope, "node_1") + assert view is not None + assert view.state is MessageState.COMPLETED + + +@pytest.mark.asyncio +async def test_duplicate_delivery_removes_its_provisional_status( + handler, + mock_platform, + mock_session_store, + incoming_message_factory, +): + incoming = incoming_message_factory(text="hello", message_id="duplicate") + mock_platform.queue_send_message.side_effect = ["status-first", "status-rejected"] + + await handler.handle_message(incoming) + await _wait_for_idle(handler) + await handler.handle_message(incoming) + + mock_platform.queue_delete_messages.assert_awaited_once_with( + incoming.chat_id, + ["status-rejected"], + fire_and_forget=False, + ) + mock_session_store.forget_tracked_message_ids.assert_called_once_with( + incoming.platform, + incoming.chat_id, + {"status-rejected"}, + ) + + +@pytest.mark.asyncio +async def test_pre_sent_status_is_edited_in_place( + handler, mock_platform, incoming_message_factory +): + incoming = incoming_message_factory( + text="hello", + message_id="node_1", + status_message_id="existing_status", + ) + + await handler.handle_message(incoming) + await _wait_for_idle(handler) + + first_edit = mock_platform.queue_edit_message.call_args_list[0] + assert first_edit.args[1] == "existing_status" + assert "Launching" in first_edit.args[2] + assert first_edit.kwargs["fire_and_forget"] is False + mock_platform.queue_send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_busy_reply_is_rendered_with_atomic_queue_position( + handler, mock_platform, mock_cli_manager, incoming_message_factory +): + started = asyncio.Event() + + async def blocking_start(*args, **kwargs): + started.set() + await asyncio.sleep(60) + if False: + yield {} + + session = MagicMock() + session.start_task = blocking_start + mock_cli_manager.get_or_create_session.return_value = ( + session, + "session_1", + False, + ) + mock_platform.queue_send_message.side_effect = ["status_root", "status_child"] + + root = incoming_message_factory(text="root", message_id="root") + await handler.handle_message(root) + await started.wait() + child = incoming_message_factory( + text="child", + message_id="child", + reply_to_message_id="status_root", + ) + await handler.handle_message(child) + + assert "position 1" in mock_platform.queue_send_message.call_args.args[1] + queued_edit = mock_platform.queue_edit_message.call_args_list[-1] + assert queued_edit.args[1] == "status_child" + assert "position 1" in queued_edit.args[2] + + await handler.stop_all_tasks() + + +@pytest.mark.asyncio +async def test_queue_position_callback_consumes_immutable_entries( + handler, mock_platform +): + queue = ( + QueueEntry( + node=NodeUiTarget( + scope=_SCOPE, + node_id="child_1", + status_message_id="status_1", + ), + position=1, + ), + QueueEntry( + node=NodeUiTarget( + scope=_SCOPE, + node_id="child_2", + status_message_id="status_2", + ), + position=2, + ), + ) + + await handler.turn_intake.update_queue_positions(queue) + await asyncio.sleep(0) + + calls = mock_platform.queue_edit_message.call_args_list + assert [call.args[1] for call in calls] == ["status_1", "status_2"] + assert "position 1" in calls[0].args[2] + assert "position 2" in calls[1].args[2] + + +@pytest.mark.asyncio +async def test_claim_started_callback_renders_processing(handler, mock_platform): + claim = _claim() + + await handler.turn_intake.mark_node_processing(claim) + await asyncio.sleep(0) + + args, kwargs = mock_platform.queue_edit_message.call_args + assert args[0:2] == ("chat_1", "status_1") + assert "Processing" in args[2] + assert kwargs["parse_mode"] == "MarkdownV2" + + +@pytest.mark.asyncio +async def test_stop_all_applies_immutable_ui_ownership_and_snapshots( + handler, mock_cli_manager, mock_platform, mock_session_store +): + workflow_owned = CancellationEffect( + node=NodeUiTarget( + scope=_SCOPE, + node_id="queued", + status_message_id="status_queued", + ), + ui_owner=CancellationUiOwner.WORKFLOW, + ) + runner_owned = CancellationEffect( + node=NodeUiTarget( + scope=_SCOPE, + node_id="active", + status_message_id="status_active", + ), + ui_owner=CancellationUiOwner.RUNNER, + ) + snapshot = _snapshot() + result = CancellationResult( + effects=(workflow_owned, runner_owned), + snapshots=(snapshot,), + ) + with patch.object( + handler.tree_queue, + "cancel_all", + AsyncMock(return_value=result), + ) as cancel_all: + outcome = await handler.stop_all_tasks() + await asyncio.sleep(0) + + assert outcome == _stop_outcome(2) + cancel_all.assert_awaited_once_with(reason=CancellationReason.STOP) + mock_cli_manager.stop_all.assert_awaited_once() + assert mock_platform.fire_and_forget.call_count == 1 + assert mock_platform.queue_edit_message.call_args.args[1] == "status_queued" + mock_session_store.save_tree_snapshot.assert_called_once_with(snapshot) + + +@pytest.mark.asyncio +async def test_stop_all_joins_voices_before_tree_transaction_and_deduplicates( + handler, + mock_cli_manager, + mock_platform, +) -> None: + events: list[str] = [] + voice = VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id=None, + delete_message_ids=frozenset({"voice"}), + ) + tree_result = CancellationResult( + effects=( + CancellationEffect( + node=NodeUiTarget( + scope=_SCOPE, + node_id="voice", + status_message_id="voice_status", + ), + ui_owner=CancellationUiOwner.WORKFLOW, + ), + ) + ) + + async def cancel_voices() -> tuple[VoiceCancellationResult, ...]: + assert not handler._state_lock.locked() + events.append("voices") + return (voice,) + + async def cancel_trees(*, reason: CancellationReason) -> CancellationResult: + assert reason is CancellationReason.STOP + assert handler._state_lock.locked() + events.append("trees") + return tree_result + + mock_platform.cancel_all_pending_voices.side_effect = cancel_voices + with patch.object(handler.tree_queue, "cancel_all", side_effect=cancel_trees): + outcome = await handler.stop_all_tasks() + await asyncio.sleep(0) + + assert outcome == _stop_outcome(1) + assert events == ["voices", "trees"] + mock_cli_manager.stop_all.assert_awaited_once() + mock_platform.queue_edit_message.assert_awaited_once() + assert mock_platform.queue_edit_message.call_args.args[1] == "voice_status" + + +@pytest.mark.asyncio +async def test_stop_all_voice_join_failure_prevents_false_transition( + handler, + mock_cli_manager, + mock_platform, +) -> None: + mock_platform.cancel_all_pending_voices.side_effect = RuntimeError( + "voice handoff cleanup failed" + ) + + with ( + patch.object( + handler.tree_queue, "cancel_all", new_callable=AsyncMock + ) as cancel, + pytest.raises(RuntimeError, match="voice handoff cleanup failed"), + ): + await handler.stop_all_tasks() + + cancel.assert_not_awaited() + mock_cli_manager.stop_all.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_stop_all_persists_committed_transition_before_cli_shutdown( + handler, + mock_cli_manager, + mock_session_store, +): + shutdown_started = asyncio.Event() + release_shutdown = asyncio.Event() + snapshot = _snapshot() + result = CancellationResult(snapshots=(snapshot,)) + + async def block_shutdown() -> None: + shutdown_started.set() + await release_shutdown.wait() + + mock_cli_manager.stop_all.side_effect = block_shutdown + with patch.object( + handler.tree_queue, + "cancel_all", + AsyncMock(return_value=result), + ): + stop_task = asyncio.create_task(handler.stop_all_tasks()) + await shutdown_started.wait() + + mock_session_store.save_tree_snapshot.assert_called_once_with(snapshot) + stop_task.cancel() + stop_task.cancel() + await asyncio.sleep(0) + assert not stop_task.done() + release_shutdown.set() + with pytest.raises(asyncio.CancelledError): + await stop_task + assert stop_task.cancelling() == 2 + + +@pytest.mark.asyncio +async def test_terminal_close_waits_past_interactive_drain_timeout( + monkeypatch, + mock_platform, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +) -> None: + monkeypatch.setattr( + "free_claude_code.messaging.trees.manager.CANCEL_TASK_DRAIN_TIMEOUT_S", + 0.01, + ) + runner_started = asyncio.Event() + cancellation_seen = asyncio.Event() + release_cleanup = asyncio.Event() + cli_stop_seen = asyncio.Event() + + async def cancellation_delayed_runner(_claim: NodeClaim) -> None: + runner_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancellation_seen.set() + await release_cleanup.wait() + raise + + async def stop_cli() -> None: + cli_stop_seen.set() + + mock_platform.queue_send_message.return_value = "status_1" + mock_cli_manager.stop_all.side_effect = stop_cli + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + mock_session_store, + platform_name="telegram", + ) + workflow._tree_queue = TreeQueueManager(cancellation_delayed_runner) + await workflow.handle_message( + incoming_message_factory(text="work", message_id="work_1") + ) + await runner_started.wait() + + close_task = asyncio.create_task(workflow.close()) + try: + await asyncio.wait_for(cancellation_seen.wait(), timeout=1) + await asyncio.wait_for(cli_stop_seen.wait(), timeout=1) + + mock_cli_manager.stop_all.assert_awaited_once() + assert close_task.done() is False + assert workflow.tree_queue.task_count() == 1 + mock_session_store.flush_pending_save.assert_not_called() + + release_cleanup.set() + await asyncio.wait_for(close_task, timeout=1) + + assert workflow.tree_queue.task_count() == 0 + mock_session_store.flush_pending_save.assert_called_once() + finally: + release_cleanup.set() + if not close_task.done(): + await asyncio.wait_for(close_task, timeout=1) + + +@pytest.mark.asyncio +async def test_node_runner_success_uses_claim_and_semantic_completion( + handler, mock_cli_manager, mock_platform, mock_session_store +): + claim = _claim(prompt="say hello") + session = _session( + [ + { + "type": "assistant", + "message": { + "content": [ + {"type": "thinking", "thinking": "Let me think"}, + {"type": "text", "text": "Hello world"}, + ] + }, + }, + {"type": "exit", "code": 0}, + ] + ) + mock_cli_manager.get_or_create_session.return_value = ( + session, + "session_1", + False, + ) + snapshot = _snapshot() + with patch.object( + handler.tree_queue, + "complete_claim", + AsyncMock(return_value=snapshot), + ) as complete_claim: + await handler.node_runner.process_node(claim) + + complete_claim.assert_awaited_once_with(claim, "session_1") + mock_session_store.save_tree_snapshot.assert_called_once_with(snapshot) + rendered = mock_platform.queue_edit_message.call_args_list[-1].args[2] + assert "✅ *Complete*" in rendered + assert "Hello world" in rendered + mock_cli_manager.get_or_create_session.assert_awaited_once_with(session_id=None) + assert session.start_task.call_args.args == ("say hello",) + assert session.start_task.call_args.kwargs == { + "session_id": None, + "fork_session": False, + } + + +@pytest.mark.asyncio +async def test_node_runner_uses_claim_parent_session_for_fork( + handler, mock_cli_manager +): + claim = _claim(parent_session_id="parent_session") + session = _session([{"type": "exit", "code": 0}]) + mock_cli_manager.get_or_create_session.return_value = ( + session, + "child_session", + False, + ) + + await handler.node_runner.process_node(claim) + + mock_cli_manager.get_or_create_session.assert_awaited_once_with( + session_id="parent_session" + ) + assert session.start_task.call_args.kwargs == { + "session_id": "parent_session", + "fork_session": True, + } + + +@pytest.mark.asyncio +async def test_session_info_records_real_session_through_manager( + handler, mock_cli_manager, mock_session_store +): + claim = _claim() + session = _session( + [ + {"type": "session_info", "session_id": "real_session"}, + {"type": "exit", "code": 0}, + ] + ) + mock_cli_manager.get_or_create_session.return_value = ( + session, + "temporary_session", + True, + ) + record_snapshot = _snapshot("record") + complete_snapshot = _snapshot("complete") + with ( + patch.object( + handler.tree_queue, + "record_session", + AsyncMock(return_value=record_snapshot), + ) as record_session, + patch.object( + handler.tree_queue, + "complete_claim", + AsyncMock(return_value=complete_snapshot), + ) as complete_claim, + ): + await handler.node_runner.process_node(claim) + + mock_cli_manager.register_real_session_id.assert_awaited_once_with( + "temporary_session", "real_session" + ) + record_session.assert_awaited_once_with(claim, "real_session") + complete_claim.assert_awaited_once_with(claim, "real_session") + assert mock_session_store.save_tree_snapshot.call_args_list == [ + ((record_snapshot,), {}), + ((complete_snapshot,), {}), + ] + + +@pytest.mark.asyncio +async def test_session_info_rejects_unregistered_real_session_without_recording_it( + handler, + mock_cli_manager, +) -> None: + from free_claude_code.messaging.node_event_pipeline import ( + handle_session_info_event, + ) + + claim = _claim() + record_session = AsyncMock() + mock_cli_manager.register_real_session_id.return_value = False + + with pytest.raises( + RuntimeError, + match=r"^Managed Claude session registration failed\.$", + ) as raised: + await handle_session_info_event( + {"type": "session_info", "session_id": "real_session"}, + claim, + None, + "temporary_session", + cli_manager=mock_cli_manager, + record_session=record_session, + ) + + assert "real_session" not in str(raised.value) + assert "temporary_session" not in str(raised.value) + record_session.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_session_limit_failure_uses_non_propagating_claim_failure( + handler, mock_cli_manager, mock_platform +): + claim = _claim() + mock_cli_manager.get_or_create_session.side_effect = RuntimeError("session limit") + result = FailureResult( + affected=(claim.node,), + queue_update=None, + snapshot=_snapshot(), + ) + with patch.object( + handler.tree_queue, + "fail_claim", + AsyncMock(return_value=result), + ) as fail_claim: + await handler.node_runner.process_node(claim) + + fail_claim.assert_awaited_once_with(claim, propagate=False) + rendered = mock_platform.queue_edit_message.call_args_list[-1].args[2] + assert "Session limit reached" in rendered + + +@pytest.mark.asyncio +async def test_non_exit_error_defers_child_failure_until_stream_ends( + handler, mock_cli_manager, mock_platform, mock_session_store +): + claim = _claim() + session = _session([{"type": "error", "error": {"message": "CLI crashed"}}]) + mock_cli_manager.get_or_create_session.return_value = ( + session, + "session_1", + False, + ) + child = NodeUiTarget( + scope=_SCOPE, + node_id="child", + status_message_id="status_child", + ) + snapshot = _snapshot() + result = FailureResult( + affected=(claim.node, child), + queue_update=None, + snapshot=snapshot, + ) + with patch.object( + handler.tree_queue, + "fail_claim", + AsyncMock(return_value=result), + ) as fail_claim: + await handler.node_runner.process_node(claim) + await asyncio.sleep(0) + + assert fail_claim.await_args_list == [ + call(claim, propagate=False), + call(claim, propagate=True), + ] + assert mock_session_store.save_tree_snapshot.call_args_list == [ + call(snapshot), + call(snapshot), + ] + rendered = "\n".join( + call.args[2] for call in mock_platform.queue_edit_message.call_args_list + ) + assert "❌ *Error*" in rendered + assert "CLI crashed" in rendered + assert "Parent task failed" in rendered + + +@pytest.mark.asyncio +async def test_provider_error_exit_does_not_mask_or_complete( + handler, mock_cli_manager, mock_platform +): + claim = _claim() + provider_error = "API Error: Request rejected (429)\nProvider rate limit reached." + session = _session( + [ + {"type": "error", "error": {"message": provider_error}}, + {"type": "exit", "code": 1}, + ] + ) + mock_cli_manager.get_or_create_session.return_value = ( + session, + "session_1", + False, + ) + failure = FailureResult( + affected=(claim.node,), + queue_update=None, + snapshot=_snapshot(), + ) + with ( + patch.object( + handler.tree_queue, + "fail_claim", + AsyncMock(return_value=failure), + ) as fail_claim, + patch.object( + handler.tree_queue, + "complete_claim", + AsyncMock(), + ) as complete_claim, + ): + await handler.node_runner.process_node(claim) + + assert fail_claim.await_args_list == [ + call(claim, propagate=False), + call(claim, propagate=True), + ] + complete_claim.assert_not_awaited() + rendered = mock_platform.queue_edit_message.call_args_list[-1].args[2] + assert "API Error: Request rejected" in rendered + assert "Process exited with code" not in rendered + assert "✅ *Complete*" not in rendered + + +@pytest.mark.asyncio +async def test_success_exit_still_renders_complete_after_non_exit_error( + handler, mock_cli_manager, mock_platform +): + claim = _claim() + session = _session( + [ + {"type": "error", "error": {"message": "recoverable warning"}}, + {"type": "exit", "code": 0}, + ] + ) + mock_cli_manager.get_or_create_session.return_value = ( + session, + "session_1", + False, + ) + failure = FailureResult( + affected=(claim.node,), + queue_update=None, + snapshot=_snapshot(), + ) + with ( + patch.object( + handler.tree_queue, + "fail_claim", + AsyncMock(return_value=failure), + ) as fail_claim, + patch.object( + handler.tree_queue, + "complete_claim", + AsyncMock(return_value=_snapshot()), + ) as complete_claim, + ): + await handler.node_runner.process_node(claim) + + fail_claim.assert_awaited_once_with( + claim, + propagate=False, + ) + complete_claim.assert_awaited_once_with(claim, "session_1") + assert ( + "✅ *Complete*" in mock_platform.queue_edit_message.call_args_list[-1].args[2] + ) + + +@pytest.mark.asyncio +async def test_unexpected_runner_exception_uses_detailed_task_failed_ui( + handler, mock_cli_manager, mock_platform +): + claim = _claim() + + async def failing_start(*args, **kwargs): + raise ValueError("runner exploded") + if False: + yield {} + + session = MagicMock() + session.start_task = failing_start + mock_cli_manager.get_or_create_session.return_value = ( + session, + "session_1", + False, + ) + failure = FailureResult( + affected=(claim.node,), + queue_update=None, + snapshot=_snapshot(), + ) + with patch.object( + handler.tree_queue, + "fail_claim", + AsyncMock(return_value=failure), + ) as fail_claim: + await handler.node_runner.process_node(claim) + + fail_claim.assert_awaited_once_with( + claim, + propagate=True, + ) + rendered = mock_platform.queue_edit_message.call_args_list[-1].args[2] + assert "Task Failed" in rendered + assert "runner exploded" in rendered + + +@pytest.mark.asyncio +async def test_stop_cancellation_preserves_partial_transcript( + handler, mock_cli_manager, mock_platform +): + claim = _claim(prompt="work") + started = asyncio.Event() + + async def start_task(*args, **kwargs): + yield { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "partial answer"}]}, + } + started.set() + await asyncio.sleep(60) + + session = MagicMock() + session.start_task = start_task + mock_cli_manager.get_or_create_session.return_value = ( + session, + "session_1", + False, + ) + failure = FailureResult( + affected=(claim.node,), + queue_update=None, + snapshot=_snapshot(), + ) + with patch.object( + handler.tree_queue, + "fail_claim", + AsyncMock(return_value=failure), + ) as fail_claim: + task = asyncio.create_task(handler.node_runner.process_node(claim)) + await started.wait() + task.cancel(CancellationReason.STOP) + await task + + fail_claim.assert_awaited_once_with( + claim, + propagate=False, + ) + rendered = mock_platform.queue_edit_message.call_args_list[-1].args[2] + assert "partial answer" in rendered + assert "⏹ *Stopped\\.*" in rendered + assert rendered.index("partial answer") < rendered.index("⏹ *Stopped\\.*") + + +@pytest.mark.asyncio +async def test_global_clear_command_deletes_returned_ids( + handler, mock_platform, incoming_message_factory +): + handler.clear_chat = AsyncMock(return_value=frozenset({"100", "101"})) + incoming = incoming_message_factory( + text="/clear", + chat_id="chat_1", + message_id="150", + ) + + await handler.handle_message(incoming) + + handler.clear_chat.assert_awaited_once_with("telegram", "chat_1") + mock_platform.queue_delete_messages.assert_awaited_once_with( + "chat_1", + ["150", "101", "100"], + fire_and_forget=False, + ) + mock_platform.queue_send_message.assert_not_awaited() + handler.session_store.record_message_id.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failure", + (RuntimeError("clear failed"), asyncio.CancelledError()), + ids=("failure", "cancellation"), +) +async def test_interrupted_global_clear_records_its_deferred_command_id( + handler, + mock_platform, + mock_session_store, + incoming_message_factory, + failure: BaseException, +) -> None: + handler.clear_chat = AsyncMock(side_effect=failure) + incoming = incoming_message_factory( + text="/clear", + chat_id="chat_1", + message_id="150", + ) + + with pytest.raises(type(failure)): + await handler.handle_message(incoming) + + mock_session_store.record_message_id.assert_called_once_with( + incoming.platform, + incoming.chat_id, + incoming.message_id, + "in", + "command", + ) + mock_platform.queue_delete_messages.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_startup_notice_is_published_and_recorded_for_clear( + handler, + mock_platform, + mock_session_store, +) -> None: + mock_platform.queue_send_message.return_value = "startup_1" + + await handler.publish_startup_notice(_startup_notice()) + + mock_platform.queue_send_message.assert_awaited_once_with( + _SCOPE.chat_id, + "🚀 *Claude Code Proxy is online\\!* \\(Bot API\\)", + parse_mode="MarkdownV2", + fire_and_forget=False, + ) + mock_session_store.record_message_id.assert_called_once_with( + _SCOPE.platform, + _SCOPE.chat_id, + "startup_1", + "out", + "startup", + ) + mock_platform.queue_delete_messages.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_startup_notice_failure_is_nonfatal_and_records_nothing( + handler, + mock_platform, + mock_session_store, +) -> None: + mock_platform.queue_send_message.side_effect = RuntimeError("unavailable") + + await handler.publish_startup_notice(_startup_notice()) + + mock_session_store.record_message_id.assert_not_called() + mock_platform.queue_delete_messages.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_startup_notice_without_delivery_receipt_records_nothing( + handler, + mock_platform, + mock_session_store, +) -> None: + mock_platform.queue_send_message.return_value = None + + await handler.publish_startup_notice(_startup_notice()) + + mock_session_store.record_message_id.assert_not_called() + mock_platform.queue_delete_messages.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_startup_notice_record_failure_is_compensated_outside_state_lock( + handler, + mock_platform, + mock_session_store, +) -> None: + mock_platform.queue_send_message.return_value = "startup_1" + mock_session_store.record_message_id.side_effect = OSError("store unavailable") + + async def delete_notice(*args, **kwargs) -> None: + assert not handler._state_lock.locked() + + mock_platform.queue_delete_messages.side_effect = delete_notice + + await handler.publish_startup_notice(_startup_notice()) + + mock_session_store.record_message_id.assert_called_once_with( + _SCOPE.platform, + _SCOPE.chat_id, + "startup_1", + "out", + "startup", + ) + mock_platform.queue_delete_messages.assert_awaited_once_with( + _SCOPE.chat_id, + ["startup_1"], + fire_and_forget=False, + ) + mock_session_store.forget_tracked_message_ids.assert_called_once_with( + _SCOPE.platform, + _SCOPE.chat_id, + {"startup_1"}, + ) + + +@pytest.mark.asyncio +async def test_failed_startup_notice_compensation_restores_clear_ownership( + handler, + mock_platform, + mock_session_store, +) -> None: + mock_platform.queue_send_message.return_value = "startup_1" + mock_session_store.record_message_id.side_effect = ( + OSError("store unavailable"), + None, + ) + mock_platform.queue_delete_messages.side_effect = RuntimeError("delete unavailable") + + await handler.publish_startup_notice(_startup_notice()) + + assert mock_session_store.record_message_id.call_count == 2 + assert mock_session_store.record_message_id.call_args_list == [ + call( + _SCOPE.platform, + _SCOPE.chat_id, + "startup_1", + "out", + "startup", + ), + call( + _SCOPE.platform, + _SCOPE.chat_id, + "startup_1", + "out", + "startup", + ), + ] + mock_session_store.forget_tracked_message_ids.assert_not_called() + + +@pytest.mark.asyncio +async def test_startup_notice_publication_propagates_cancellation( + handler, + mock_platform, + mock_session_store, +) -> None: + send_started = asyncio.Event() + send_cancelled = asyncio.Event() + + async def send_notice(*args, **kwargs) -> None: + send_started.set() + try: + await asyncio.Event().wait() + finally: + send_cancelled.set() + + mock_platform.queue_send_message.side_effect = send_notice + task = asyncio.create_task(handler.publish_startup_notice(_startup_notice())) + await send_started.wait() + + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + await send_cancelled.wait() + mock_session_store.record_message_id.assert_not_called() + mock_platform.queue_delete_messages.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_startup_notice_cancellation_after_receipt_finishes_compensation( + handler, + mock_platform, + mock_session_store, +) -> None: + send_started = asyncio.Event() + release_send = asyncio.Event() + finalizer_started = asyncio.Event() + + async def send_notice(*args, **kwargs) -> str: + send_started.set() + await release_send.wait() + return "startup_1" + + original_finalize = handler._finalize_startup_notice + + async def finalize_notice(*args, **kwargs) -> None: + finalizer_started.set() + await original_finalize(*args, **kwargs) + + mock_platform.queue_send_message.side_effect = send_notice + with patch.object( + handler, + "_finalize_startup_notice", + side_effect=finalize_notice, + ): + task = asyncio.create_task(handler.publish_startup_notice(_startup_notice())) + await send_started.wait() + await handler._state_lock.acquire() + try: + release_send.set() + await finalizer_started.wait() + finally: + handler._state_lock.release() + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + mock_session_store.record_message_id.assert_not_called() + mock_platform.queue_delete_messages.assert_awaited_once_with( + _SCOPE.chat_id, + ["startup_1"], + fire_and_forget=False, + ) + mock_session_store.forget_tracked_message_ids.assert_called_once_with( + _SCOPE.platform, + _SCOPE.chat_id, + {"startup_1"}, + ) + + +@pytest.mark.asyncio +async def test_concurrent_global_clear_does_not_wait_for_startup_delivery( + mock_platform, + mock_cli_manager, + tmp_path, +) -> None: + store = SessionStore(storage_path=str(tmp_path / "sessions.json")) + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + store, + platform_name="telegram", + voice_cancellation=mock_platform, + ) + send_started = asyncio.Event() + release_send = asyncio.Event() + + async def send_notice(*args, **kwargs) -> str: + send_started.set() + await release_send.wait() + return "startup_1" + + mock_platform.queue_send_message.side_effect = send_notice + publish_task = asyncio.create_task( + workflow.publish_startup_notice(_startup_notice()) + ) + await send_started.wait() + clear_task = asyncio.create_task( + workflow.clear_chat(_SCOPE.platform, _SCOPE.chat_id) + ) + try: + done, _pending = await asyncio.wait({clear_task}, timeout=1) + assert clear_task in done + assert clear_task.result() == frozenset() + finally: + release_send.set() + await asyncio.gather(publish_task, clear_task, return_exceptions=True) + + assert store.get_tracked_message_ids_for_chat(_SCOPE.platform, _SCOPE.chat_id) == [] + mock_platform.queue_delete_messages.assert_awaited_once_with( + _SCOPE.chat_id, + ["startup_1"], + fire_and_forget=False, + ) + + +@pytest.mark.asyncio +async def test_global_stop_does_not_wait_for_or_invalidate_startup_delivery( + handler, + mock_platform, + mock_session_store, +) -> None: + send_started = asyncio.Event() + release_send = asyncio.Event() + + async def send_notice(*args, **kwargs) -> str: + send_started.set() + await release_send.wait() + return "startup_1" + + mock_platform.queue_send_message.side_effect = send_notice + publish_task = asyncio.create_task( + handler.publish_startup_notice(_startup_notice()) + ) + await send_started.wait() + stop_task = asyncio.create_task(handler.stop_all_tasks()) + try: + done, _pending = await asyncio.wait({stop_task}, timeout=1) + assert stop_task in done + stop_task.result() + finally: + release_send.set() + await asyncio.gather(publish_task, stop_task, return_exceptions=True) + + mock_session_store.record_message_id.assert_called_once_with( + _SCOPE.platform, + _SCOPE.chat_id, + "startup_1", + "out", + "startup", + ) + mock_platform.queue_delete_messages.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_global_clear_precedes_concurrent_startup_notice_publication( + mock_platform, + mock_cli_manager, + tmp_path, +) -> None: + store = SessionStore(storage_path=str(tmp_path / "sessions.json")) + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + store, + platform_name="telegram", + voice_cancellation=mock_platform, + ) + clear_started = asyncio.Event() + release_clear = asyncio.Event() + + async def clear_trees( + scope: MessageScope, *, reason: CancellationReason + ) -> CancellationResult: + assert reason is CancellationReason.CLEAR + clear_started.set() + await release_clear.wait() + return CancellationResult() + + with patch.object(workflow.tree_queue, "clear_scope", side_effect=clear_trees): + clear_task = asyncio.create_task( + workflow.clear_chat(_SCOPE.platform, _SCOPE.chat_id) + ) + await clear_started.wait() + publish_task = asyncio.create_task( + workflow.publish_startup_notice(_startup_notice()) + ) + await asyncio.sleep(0) + mock_platform.queue_send_message.assert_not_awaited() + + release_clear.set() + + assert await clear_task == frozenset() + await publish_task + + assert store.get_tracked_message_ids_for_chat(_SCOPE.platform, _SCOPE.chat_id) == [ + "msg_123" + ] + mock_platform.queue_delete_messages.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_clear_command_cannot_evict_startup_notice_at_managed_message_cap( + mock_platform, + mock_cli_manager, + incoming_message_factory, + tmp_path, +) -> None: + store = SessionStore( + storage_path=str(tmp_path / "sessions.json"), + managed_message_cap=1, + ) + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + store, + platform_name="telegram", + voice_cancellation=mock_platform, + ) + store.record_message_id( + _SCOPE.platform, + _SCOPE.chat_id, + "100", + "out", + "startup", + ) + incoming = incoming_message_factory( + text="/clear", + chat_id=_SCOPE.chat_id, + message_id="101", + ) + + await workflow.handle_message(incoming) + + mock_platform.queue_delete_messages.assert_awaited_once_with( + _SCOPE.chat_id, + ["101", "100"], + fire_and_forget=False, + ) + + +@pytest.mark.asyncio +async def test_clear_chat_is_fully_scoped_to_the_invoking_chat( + handler, mock_cli_manager, mock_session_store, incoming_message_factory +): + root_1 = incoming_message_factory( + text="one", + chat_id="chat_1", + message_id="100", + ) + root_2 = incoming_message_factory( + text="two", + chat_id="chat_2", + message_id="200", + ) + await handler.tree_queue.admit(root_1, "101") + await handler.tree_queue.admit(root_2, "201") + await _wait_for_idle(handler) + mock_session_store.get_tracked_message_ids_for_chat.return_value = ["42"] + mock_session_store.reset_mock() + mock_session_store.get_tracked_message_ids_for_chat.return_value = ["42"] + + message_ids = await handler.clear_chat("telegram", "chat_1") + + assert message_ids == frozenset({"42", "100", "101"}) + assert "200" not in message_ids + assert handler.get_tree_count() == 1 + assert await handler.tree_queue.get_node(root_2.scope, "200") is not None + mock_cli_manager.stop_all.assert_not_awaited() + mock_session_store.clear_scope.assert_called_once_with(_SCOPE) + + +@pytest.mark.asyncio +async def test_global_clear_cancels_and_deletes_only_current_chat_voices( + handler, + mock_platform, + mock_session_store, +) -> None: + events: list[str] = [] + current = VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id="voice_status", + delete_message_ids=frozenset({"voice", "voice_status"}), + ) + + async def cancel_voices(scope: MessageScope) -> tuple[VoiceCancellationResult, ...]: + assert not handler._state_lock.locked() + assert scope == _SCOPE + events.append("voices") + return (current,) + + async def clear_trees( + scope: MessageScope, *, reason: CancellationReason + ) -> CancellationResult: + assert reason is CancellationReason.CLEAR + assert handler._state_lock.locked() + events.append("trees") + return CancellationResult() + + mock_platform.cancel_pending_voices_in_scope.side_effect = cancel_voices + mock_session_store.get_tracked_message_ids_for_chat.return_value = ["stored"] + with patch.object(handler.tree_queue, "clear_scope", side_effect=clear_trees): + message_ids = await handler.clear_chat("telegram", "chat_1") + await asyncio.sleep(0) + + assert events == ["voices", "trees"] + assert message_ids == frozenset({"stored", "voice", "voice_status"}) + assert "other_voice" not in message_ids + assert "other_status" not in message_ids + mock_platform.queue_edit_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_global_clear_persists_after_tree_detach( + handler, + mock_cli_manager, + mock_session_store, +) -> None: + events: list[str] = [] + + async def clear_trees( + scope: MessageScope, *, reason: CancellationReason + ) -> CancellationResult: + assert scope == _SCOPE + assert reason is CancellationReason.CLEAR + events.append("trees.clear_scope") + return CancellationResult() + + mock_session_store.clear_scope.side_effect = lambda scope: events.append( + f"store.clear_scope:{scope.chat_id}" + ) + + with patch.object(handler.tree_queue, "clear_scope", side_effect=clear_trees): + result = await handler.clear_chat("telegram", "chat_1") + + assert result == frozenset() + assert events == ["trees.clear_scope", "store.clear_scope:chat_1"] + mock_cli_manager.stop_all.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_global_clear_finishes_cleanup_before_final_persistence_error_escapes( + handler, + mock_cli_manager, + mock_session_store, +) -> None: + events: list[str] = [] + + async def clear_trees( + scope: MessageScope, *, reason: CancellationReason + ) -> CancellationResult: + assert scope == _SCOPE + assert reason is CancellationReason.CLEAR + events.append("trees.clear_scope") + return CancellationResult() + + def fail_final_clear(scope: MessageScope) -> None: + assert scope == _SCOPE + events.append("store.clear_scope") + raise OSError("final clear failure") + + mock_session_store.clear_scope.side_effect = fail_final_clear + + with ( + patch.object(handler.tree_queue, "clear_scope", side_effect=clear_trees), + pytest.raises(OSError, match="final clear failure"), + ): + await handler.clear_chat("telegram", "chat_1") + + assert events == ["trees.clear_scope", "store.clear_scope"] + mock_cli_manager.stop_all.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_global_clear_preserves_tree_and_persistence_failures( + handler, + mock_cli_manager, + mock_session_store, +) -> None: + events: list[str] = [] + persistence_error = OSError("final clear failure") + tree_error = RuntimeError("tree clear failure") + + async def fail_tree_clear( + scope: MessageScope, *, reason: CancellationReason + ) -> CancellationResult: + assert scope == _SCOPE + assert reason is CancellationReason.CLEAR + events.append("trees.clear_scope") + raise tree_error + + def fail_final_clear(scope: MessageScope) -> None: + assert scope == _SCOPE + events.append("store.clear_scope") + raise persistence_error + + mock_session_store.clear_scope.side_effect = fail_final_clear + + with ( + patch.object(handler.tree_queue, "clear_scope", side_effect=fail_tree_clear), + pytest.raises(ExceptionGroup) as raised, + ): + await handler.clear_chat("telegram", "chat_1") + + assert raised.value.exceptions == (tree_error, persistence_error) + assert events == ["trees.clear_scope", "store.clear_scope"] + mock_cli_manager.stop_all.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_committed_global_clear_attempts_remaining_steps_after_tree_failure( + handler, + mock_cli_manager, + mock_session_store, +) -> None: + events: list[str] = [] + tree_error = RuntimeError("tree clear failure") + + async def fail_tree_clear( + scope: MessageScope, *, reason: CancellationReason + ) -> CancellationResult: + assert scope == _SCOPE + assert reason is CancellationReason.CLEAR + events.append("trees.clear_scope") + raise tree_error + + mock_session_store.clear_scope.side_effect = lambda scope: events.append( + f"store.clear_scope:{scope.chat_id}" + ) + + with ( + patch.object(handler.tree_queue, "clear_scope", side_effect=fail_tree_clear), + pytest.raises(RuntimeError, match="tree clear failure") as raised, + ): + await handler.clear_chat("telegram", "chat_1") + + assert raised.value is tree_error + assert events == [ + "trees.clear_scope", + "store.clear_scope:chat_1", + ] + mock_cli_manager.stop_all.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cancelled_global_clear_finishes_owned_transaction_before_propagating( + handler, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +): + root = incoming_message_factory(text="work", message_id="100") + await handler.tree_queue.admit(root, "101") + await _wait_for_idle(handler) + id_read_started = asyncio.Event() + release_id_read = asyncio.Event() + + async def block_id_read(platform: str, chat_id: str) -> set[str]: + id_read_started.set() + await release_id_read.wait() + return {"100", "101"} + + mock_session_store.reset_mock() + with patch.object( + handler.tree_queue, + "get_message_ids_for_chat", + new=block_id_read, + ): + clear_task = asyncio.create_task(handler.clear_chat("telegram", root.chat_id)) + await id_read_started.wait() + clear_task.cancel() + await asyncio.sleep(0) + assert not clear_task.done() + release_id_read.set() + with pytest.raises(asyncio.CancelledError): + await clear_task + + assert handler._clear_generations[_SCOPE] == 1 + assert handler.get_tree_count() == 0 + mock_session_store.clear_scope.assert_called_once_with(_SCOPE) + mock_cli_manager.stop_all.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_clear_with_mention_uses_same_global_command( + handler, mock_platform, incoming_message_factory +): + handler.clear_chat = AsyncMock(return_value=frozenset()) + incoming = incoming_message_factory( + text="/clear@MyBot", + chat_id="chat_1", + message_id="10", + ) + + await handler.handle_message(incoming) + + handler.clear_chat.assert_awaited_once_with("telegram", "chat_1") + mock_platform.queue_delete_messages.assert_awaited_once_with( + "chat_1", + ["10"], + fire_and_forget=False, + ) + + +@pytest.mark.asyncio +async def test_clear_continues_after_platform_delete_failure( + handler, mock_platform, incoming_message_factory +): + handler.clear_chat = AsyncMock(return_value=frozenset({"41", "42"})) + mock_platform.queue_delete_messages.side_effect = RuntimeError( + "platform rejected delete" + ) + + await handler.handle_message( + incoming_message_factory(text="/clear", message_id="150") + ) + + handler.clear_chat.assert_awaited_once() + mock_platform.queue_delete_messages.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_reply_clear_status_preserves_prompt_and_persists_remaining_tree( + handler, + mock_platform, + mock_session_store, + incoming_message_factory, +): + root = incoming_message_factory(text="root", message_id="100") + child = incoming_message_factory( + text="child", + message_id="102", + reply_to_message_id="100", + ) + await handler.tree_queue.admit(root, "101") + await _wait_for_idle(handler) + await handler.tree_queue.admit(child, "103", parent_reference_id="100") + await _wait_for_idle(handler) + mock_session_store.reset_mock() + deleted_ids: list[str] = [] + + async def capture_delete(chat_id, message_ids, fire_and_forget=True): + deleted_ids.extend(message_ids) + + mock_platform.queue_delete_messages.side_effect = capture_delete + await handler.handle_message( + incoming_message_factory( + text="/clear", + message_id="150", + reply_to_message_id="103", + ) + ) + + assert set(deleted_ids) == {"103", "150"} + assert "102" not in deleted_ids + assert "100" not in deleted_ids + assert "101" not in deleted_ids + cleared_prompt = await handler.tree_queue.get_node(root.scope, "102") + assert cleared_prompt is not None + assert cleared_prompt.state is MessageState.ERROR + assert cleared_prompt.session_id is None + assert await handler.tree_queue.get_node(root.scope, "100") is not None + mock_session_store.save_tree_snapshot.assert_called_once() + mock_session_store.record_message_id.assert_called_once_with( + "telegram", + "chat_1", + "150", + "in", + "command", + ) + mock_session_store.forget_tracked_message_ids.assert_called_once_with( + "telegram", + "chat_1", + {"103", "150"}, + ) + + +@pytest.mark.asyncio +async def test_reply_clear_unknown_reports_nothing_to_clear( + handler, mock_platform, mock_session_store, incoming_message_factory +): + incoming = incoming_message_factory( + text="/clear", + message_id="150", + reply_to_message_id="999", + ) + + await handler.handle_message(incoming) + + assert "Nothing to clear" in mock_platform.queue_send_message.call_args.args[1] + mock_session_store.record_message_id.assert_any_call( + incoming.platform, + incoming.chat_id, + incoming.message_id, + "in", + "command", + ) + mock_session_store.clear_scope.assert_not_called() + + +@pytest.mark.asyncio +async def test_reply_clear_root_removes_tree_snapshot( + handler, + mock_platform, + mock_session_store, + incoming_message_factory, +): + root = incoming_message_factory(text="root", message_id="100") + await handler.tree_queue.admit(root, "101") + await _wait_for_idle(handler) + mock_session_store.reset_mock() + deleted_ids: list[str] = [] + + async def capture_delete(chat_id, message_ids, fire_and_forget=True): + deleted_ids.extend(message_ids) + + mock_platform.queue_delete_messages.side_effect = capture_delete + await handler.handle_message( + incoming_message_factory( + text="/clear", + message_id="150", + reply_to_message_id="100", + ) + ) + + assert set(deleted_ids) == {"100", "101", "150"} + mock_session_store.remove_tree_snapshot.assert_called_once_with( + TreeIdentity(scope=root.scope, root_id="100") + ) + assert handler.get_tree_count() == 0 + + +@pytest.mark.asyncio +async def test_late_cancelled_runner_cannot_save_or_render_after_chat_clear( + handler, + mock_platform, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +): + started = asyncio.Event() + + async def blocking_start(*args, **kwargs): + started.set() + await asyncio.sleep(60) + if False: + yield {} + + session = MagicMock() + session.start_task = blocking_start + mock_cli_manager.get_or_create_session.return_value = ( + session, + "pending_1", + True, + ) + await handler.handle_message( + incoming_message_factory(text="work", message_id="100") + ) + await started.wait() + mock_session_store.reset_mock() + mock_platform.queue_edit_message.reset_mock() + + await handler.clear_chat("telegram", "chat_1") + + mock_session_store.save_tree_snapshot.assert_not_called() + mock_session_store.clear_scope.assert_called_once_with(_SCOPE) + mock_platform.queue_edit_message.assert_not_awaited() + assert handler.get_tree_count() == 0 + + +@pytest.mark.asyncio +async def test_global_clear_removes_snapshot_saved_during_detach_window( + tmp_path, + mock_platform, + mock_cli_manager, + incoming_message_factory, +): + runner_started = asyncio.Event() + release_runner = asyncio.Event() + id_read_started = asyncio.Event() + release_id_read = asyncio.Event() + + async def finish_after_release(*args, **kwargs): + runner_started.set() + await release_runner.wait() + yield {"type": "exit", "code": 0} + + session = MagicMock() + session.start_task = finish_after_release + mock_cli_manager.get_or_create_session.return_value = ( + session, + "session_1", + False, + ) + mock_platform.queue_send_message.return_value = "status-new" + store_path = tmp_path / "sessions.json" + store = SessionStore(storage_path=str(store_path)) + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + store, + platform_name="telegram", + ) + incoming = incoming_message_factory(text="work", message_id="new") + await workflow.handle_message(incoming) + await runner_started.wait() + + get_ids = workflow.tree_queue.get_message_ids_for_chat + + async def block_id_read(platform: str, chat_id: str) -> set[str]: + id_read_started.set() + await release_id_read.wait() + return await get_ids(platform, chat_id) + + try: + with patch.object( + workflow.tree_queue, + "get_message_ids_for_chat", + new=block_id_read, + ): + clear_task = asyncio.create_task( + workflow.clear_chat("telegram", incoming.chat_id) + ) + await id_read_started.wait() + release_runner.set() + await _wait_for_idle(workflow) + + assert not store.load_conversation_snapshot().is_empty + store.flush_pending_save() + assert ( + not SessionStore(storage_path=str(store_path)) + .load_conversation_snapshot() + .is_empty + ) + release_id_read.set() + await clear_task + + assert store.load_conversation_snapshot().is_empty + assert ( + SessionStore(storage_path=str(store_path)) + .load_conversation_snapshot() + .is_empty + ) + finally: + release_runner.set() + release_id_read.set() + await workflow.close() + + +@pytest.mark.asyncio +async def test_global_clear_invalidates_inflight_prompt_without_waiting_for_status( + handler, + mock_platform, + incoming_message_factory, +): + status_send_started = asyncio.Event() + release_status_send = asyncio.Event() + + async def block_status_send(*args, **kwargs): + status_send_started.set() + await release_status_send.wait() + return "status-new" + + mock_platform.queue_send_message.side_effect = block_status_send + prompt_task = asyncio.create_task( + handler.handle_message( + incoming_message_factory(text="new prompt", message_id="new") + ) + ) + await status_send_started.wait() + clear_task = asyncio.create_task(handler.clear_chat("telegram", "chat_1")) + + try: + await asyncio.wait_for(clear_task, timeout=1) + release_status_send.set() + await prompt_task + assert handler.get_tree_count() == 0 + mock_platform.queue_delete_messages.assert_awaited_once_with( + "chat_1", + ["status-new"], + fire_and_forget=False, + ) + finally: + release_status_send.set() + if not prompt_task.done(): + prompt_task.cancel() + if not clear_task.done(): + clear_task.cancel() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("status_message_id", "expected_deleted_ids"), + [ + ("101", {"100", "101", "150"}), + (None, {"100", "150"}), + ], +) +async def test_reply_clear_pending_voice_cancels_and_reports( + handler, + mock_platform, + incoming_message_factory, + status_message_id, + expected_deleted_ids, +) -> None: + delete_message_ids = {"100"} + if status_message_id is not None: + delete_message_ids.add(status_message_id) + handler.clear_reply = AsyncMock( + return_value=ReplyClearResult( + delete_message_ids=frozenset(delete_message_ids), + tree_matched=False, + ) + ) + deleted_ids: list[str] = [] + + async def capture_delete(chat_id, message_ids, fire_and_forget=True): + deleted_ids.extend(message_ids) + + mock_platform.queue_delete_messages.side_effect = capture_delete + incoming = incoming_message_factory( + text="/clear", + message_id="150", + reply_to_message_id="100", + ) + + await handler.handle_message(incoming) + + handler.clear_reply.assert_awaited_once_with(incoming.scope, "100") + assert set(deleted_ids) == expected_deleted_ids + mock_platform.queue_send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reply_clear_deletes_owned_result_ids( + handler, + mock_platform, + incoming_message_factory, +) -> None: + handler.clear_reply = AsyncMock( + return_value=ReplyClearResult( + delete_message_ids=frozenset({"tree_status"}), + tree_matched=True, + ) + ) + deleted_ids: list[str] = [] + + async def capture_delete(_chat_id, message_ids, fire_and_forget=True): + deleted_ids.extend(message_ids) + + mock_platform.queue_delete_messages.side_effect = capture_delete + incoming = incoming_message_factory( + text="/clear", + message_id="clear", + reply_to_message_id="voice", + ) + + await handler.handle_message(incoming) + + handler.clear_reply.assert_awaited_once_with(incoming.scope, "voice") + assert set(deleted_ids) == { + "tree_status", + "clear", + } + assert "voice" not in deleted_ids + mock_platform.queue_send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reply_clear_joins_voice_then_unions_authoritative_branch_ids( + handler, + mock_platform, +) -> None: + events: list[str] = [] + + async def cancel_voice(*_args) -> VoiceCancellationResult: + events.append("voice") + return VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id="voice_status", + delete_message_ids=frozenset({"voice", "voice_status"}), + ) + + branch = MessageSubtreeRemovalResult( + cancellation=CancellationResult(), + removed_tree_identity=None, + delete_message_ids=frozenset({"tree_status"}), + tree_matched=True, + ) + mock_platform.cancel_pending_voice.side_effect = cancel_voice + with patch.object( + handler.tree_queue, + "remove_message_subtree", + new_callable=AsyncMock, + return_value=branch, + ) as remove_message_subtree: + result = await handler.clear_reply(_SCOPE, "voice") + await asyncio.sleep(0) + + assert result == ReplyClearResult( + delete_message_ids=frozenset({"voice", "voice_status", "tree_status"}), + tree_matched=True, + ) + assert events == ["voice"] + remove_message_subtree.assert_awaited_once_with( + _SCOPE, + "voice", + reason=CancellationReason.CLEAR, + ) + mock_platform.queue_edit_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_cancelled_reply_clear_finishes_voice_only_owner_after_lock_wait( + handler, + mock_platform, +) -> None: + voice_returned = asyncio.Event() + + async def cancel_voice(*_args) -> VoiceCancellationResult: + voice_returned.set() + return VoiceCancellationResult( + scope=_SCOPE, + voice_message_id="voice", + status_message_id="voice_status", + delete_message_ids=frozenset({"voice", "voice_status"}), + ) + + mock_platform.cancel_pending_voice.side_effect = cancel_voice + remove_subtree = AsyncMock( + return_value=MessageSubtreeRemovalResult( + cancellation=CancellationResult(), + removed_tree_identity=None, + delete_message_ids=frozenset(), + tree_matched=False, + ) + ) + await handler._state_lock.acquire() + try: + with patch.object( + handler.tree_queue, + "remove_message_subtree", + remove_subtree, + ): + clear_task = asyncio.create_task(handler.clear_reply(_SCOPE, "voice")) + await voice_returned.wait() + await asyncio.sleep(0) + clear_task.cancel() + await asyncio.sleep(0) + + assert not clear_task.done() + handler._state_lock.release() + with pytest.raises(asyncio.CancelledError): + await clear_task + finally: + if handler._state_lock.locked(): + handler._state_lock.release() + await asyncio.sleep(0) + + remove_subtree.assert_awaited_once_with( + _SCOPE, + "voice", + reason=CancellationReason.CLEAR, + ) + mock_platform.queue_edit_message.assert_not_awaited() diff --git a/tests/messaging/test_handler_context_isolation.py b/tests/messaging/test_handler_context_isolation.py new file mode 100644 index 0000000..6470e91 --- /dev/null +++ b/tests/messaging/test_handler_context_isolation.py @@ -0,0 +1,93 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from free_claude_code.messaging.workflow import MessagingWorkflow + + +async def _wait_for_idle(handler: MessagingWorkflow) -> None: + for _ in range(100): + if handler.tree_queue.task_count() == 0: + return + await asyncio.sleep(0) + raise AssertionError("messaging claims did not finish") + + +def _session_factory(calls: list[tuple[str, str | None, bool]]): + async def get_or_create_session(session_id=None): + session = MagicMock() + + async def start_task(prompt, session_id=None, fork_session=False): + calls.append((prompt, session_id, fork_session)) + yield {"type": "session_info", "session_id": f"sess_{prompt}"} + yield {"type": "exit", "code": 0, "stderr": None} + + session.start_task = start_task + return session, f"pending_{len(calls)}", True + + return get_or_create_session + + +@pytest.mark.asyncio +async def test_sibling_replies_fork_from_parent_session_id( + mock_platform, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +) -> None: + calls: list[tuple[str, str | None, bool]] = [] + mock_cli_manager.get_or_create_session = AsyncMock( + side_effect=_session_factory(calls) + ) + mock_platform.queue_send_message = AsyncMock( + side_effect=["status_A", "status_R1", "status_R2"] + ) + handler = MessagingWorkflow(mock_platform, mock_cli_manager, mock_session_store) + + await handler.handle_message(incoming_message_factory(text="A", message_id="A")) + await _wait_for_idle(handler) + await handler.handle_message( + incoming_message_factory(text="R1", message_id="R1", reply_to_message_id="A") + ) + await _wait_for_idle(handler) + await handler.handle_message( + incoming_message_factory(text="R2", message_id="R2", reply_to_message_id="A") + ) + await _wait_for_idle(handler) + + assert calls == [ + ("A", None, False), + ("R1", "sess_A", True), + ("R2", "sess_A", True), + ] + + +@pytest.mark.asyncio +async def test_grandchild_reply_forks_from_branch_session( + mock_platform, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +) -> None: + calls: list[tuple[str, str | None, bool]] = [] + mock_cli_manager.get_or_create_session = AsyncMock( + side_effect=_session_factory(calls) + ) + mock_platform.queue_send_message = AsyncMock( + side_effect=["status_A", "status_R1", "status_C1"] + ) + handler = MessagingWorkflow(mock_platform, mock_cli_manager, mock_session_store) + + await handler.handle_message(incoming_message_factory(text="A", message_id="A")) + await _wait_for_idle(handler) + await handler.handle_message( + incoming_message_factory(text="R1", message_id="R1", reply_to_message_id="A") + ) + await _wait_for_idle(handler) + await handler.handle_message( + incoming_message_factory(text="C1", message_id="C1", reply_to_message_id="R1") + ) + await _wait_for_idle(handler) + + assert calls[-1] == ("C1", "sess_R1", True) diff --git a/tests/messaging/test_handler_format.py b/tests/messaging/test_handler_format.py new file mode 100644 index 0000000..b689ed4 --- /dev/null +++ b/tests/messaging/test_handler_format.py @@ -0,0 +1,131 @@ +from unittest.mock import MagicMock + +import pytest + +from free_claude_code.messaging.rendering.telegram_markdown import ( + escape_md_v2, + escape_md_v2_code, + mdv2_bold, + mdv2_code_inline, + render_markdown_to_mdv2, +) +from free_claude_code.messaging.transcript import RenderCtx, TranscriptBuffer + + +@pytest.fixture +def handler(): + platform = MagicMock() + cli = MagicMock() + store = MagicMock() + return (platform, cli, store) + + +def _ctx() -> RenderCtx: + return RenderCtx( + bold=mdv2_bold, + code_inline=mdv2_code_inline, + escape_code=escape_md_v2_code, + escape_text=escape_md_v2, + render_markdown=render_markdown_to_mdv2, + ) + + +def test_transcript_structure_and_order(handler): + """Verify ordered transcript rendering (thinking/tool/subagent/text/error/status).""" + status = "✅ *Complete*" + t = TranscriptBuffer() + + # Apply in a deliberate sequence. + t.apply({"type": "thinking_chunk", "text": "Thinking process..."}) + t.apply( + {"type": "tool_use", "id": "t1", "name": "list_files", "input": {"path": "."}} + ) + + # Subagent marker (Task tool). + t.apply( + { + "type": "tool_use", + "id": "task1", + "name": "Task", + "input": {"description": "Searching codebase..."}, + } + ) + t.apply( + {"type": "tool_use", "id": "t2", "name": "read_file", "input": {"path": "x.py"}} + ) + t.apply({"type": "tool_result", "tool_use_id": "task1", "content": "done"}) + + t.apply({"type": "text_chunk", "text": "Here is the file content."}) + t.apply({"type": "error", "message": "Some error happened"}) + + msg = t.render(_ctx(), limit_chars=3900, status=status) + + print(f"Generated Message:\n{msg}") + + # Check existence + assert "Thinking process..." in msg + assert "list_files" in msg + assert "read_file" in msg + assert "Searching codebase..." in msg + assert escape_md_v2("Here is the file content.") in msg + assert "Some error happened" in msg + assert "✅ *Complete*" in msg + + # Check headers/markers used in the transcript. + assert "💭 *Thinking*" in msg + assert "🛠 *Tool call:*" in msg + assert "🤖 *Subagent:*" in msg + assert "⚠️ *Error:*" in msg + + # Check Order: Thinking -> Tool call -> Subagent -> Content -> Errors -> Status + p_thinking = msg.find("Thinking process...") + p_tool_call = msg.find("🛠 *Tool call:*") + p_subagent = msg.find("🤖 *Subagent:*") + p_content = msg.find(escape_md_v2("Here is the file content.")) + p_errors = msg.find("⚠️ *Error:*") + p_status = msg.find("✅ *Complete*") + + assert p_thinking < p_tool_call, "Thinking should come before tool calls" + assert p_tool_call < p_subagent, "Tool calls should come before subagent marker" + assert p_subagent < p_content, "Subagent should come before Content" + assert p_content < p_errors, "Content should come before Errors" + assert p_errors < p_status, "Errors should come before Status" + + +def test_transcript_simple(handler): + """Verify simple transcript with just text + status.""" + t = TranscriptBuffer() + t.apply({"type": "text_chunk", "text": "Simple message."}) + msg = t.render(_ctx(), limit_chars=3900, status="Ready") + + assert escape_md_v2("Simple message.") in msg + assert "Ready" in msg + assert "💭" not in msg + assert "🛠" not in msg + + +def test_subagents_formatting(handler): + """Verify subagent formatting (Task tool).""" + t = TranscriptBuffer() + t.apply( + { + "type": "tool_use", + "id": "task_1", + "name": "Task", + "input": {"description": "Task 1"}, + } + ) + t.apply({"type": "tool_result", "tool_use_id": "task_1", "content": "done"}) + t.apply( + { + "type": "tool_use", + "id": "task_2", + "name": "Task", + "input": {"description": "Task 2"}, + } + ) + + msg = t.render(_ctx(), limit_chars=3900, status=None) + + assert "🤖 *Subagent:* `Task 1`" in msg + assert "🤖 *Subagent:* `Task 2`" in msg diff --git a/tests/messaging/test_handler_integration.py b/tests/messaging/test_handler_integration.py new file mode 100644 index 0000000..bb85a18 --- /dev/null +++ b/tests/messaging/test_handler_integration.py @@ -0,0 +1,163 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.trees import MessageState +from free_claude_code.messaging.workflow import MessagingWorkflow + +_SCOPE = MessageScope(platform="telegram", chat_id="chat_1") + + +@pytest.fixture +def handler_integration(mock_platform, mock_cli_manager, mock_session_store): + return MessagingWorkflow(mock_platform, mock_cli_manager, mock_session_store) + + +async def _events(events): + for event in events: + yield event + + +async def _wait_for_idle(handler: MessagingWorkflow) -> None: + for _ in range(100): + if handler.tree_queue.task_count() == 0: + return + await asyncio.sleep(0) + raise AssertionError("messaging claims did not finish") + + +@pytest.mark.asyncio +async def test_full_conversation_flow_single_user( + handler_integration, + mock_platform, + mock_cli_manager, + incoming_message_factory, +) -> None: + mock_platform.queue_send_message = AsyncMock(side_effect=["s1", "s2"]) + root_session = MagicMock() + root_session.start_task.return_value = _events( + [ + {"type": "session_info", "session_id": "sess1"}, + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Reply 1"}]}, + }, + {"type": "exit", "code": 0, "stderr": None}, + ] + ) + reply_session = MagicMock() + reply_session.start_task.return_value = _events( + [ + {"type": "session_info", "session_id": "sess2"}, + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "Reply 2"}]}, + }, + {"type": "exit", "code": 0, "stderr": None}, + ] + ) + mock_cli_manager.get_or_create_session.side_effect = [ + (root_session, "pending_1", True), + (reply_session, "pending_2", True), + ] + + await handler_integration.handle_message( + incoming_message_factory(text="message 1", message_id="m1") + ) + await _wait_for_idle(handler_integration) + root = await handler_integration.tree_queue.get_node(_SCOPE, "m1") + assert root is not None + assert root.state is MessageState.COMPLETED + assert root.session_id == "sess1" + + await handler_integration.handle_message( + incoming_message_factory( + text="message 2", + message_id="m2", + reply_to_message_id="m1", + ) + ) + await _wait_for_idle(handler_integration) + reply = await handler_integration.tree_queue.get_node(_SCOPE, "m2") + assert reply is not None + assert reply.state is MessageState.COMPLETED + assert reply.parent_id == "m1" + mock_cli_manager.get_or_create_session.assert_called_with(session_id="sess1") + reply_session.start_task.assert_called_with( + "message 2", session_id="sess1", fork_session=True + ) + + +@pytest.mark.asyncio +async def test_error_propagation_chain( + handler_integration, + mock_platform, + mock_cli_manager, + incoming_message_factory, +) -> None: + started = asyncio.Event() + release_error = asyncio.Event() + + async def failing_events(): + started.set() + await release_error.wait() + yield {"type": "error", "error": {"message": "failed"}} + + session = MagicMock() + session.start_task.return_value = failing_events() + mock_cli_manager.get_or_create_session.return_value = (session, "sess1", False) + mock_platform.queue_send_message = AsyncMock(side_effect=["s1", "s2"]) + + await handler_integration.handle_message( + incoming_message_factory(text="m1", message_id="m1") + ) + await started.wait() + await handler_integration.handle_message( + incoming_message_factory(text="m2", message_id="m2", reply_to_message_id="m1") + ) + release_error.set() + await _wait_for_idle(handler_integration) + + root = await handler_integration.tree_queue.get_node(_SCOPE, "m1") + child = await handler_integration.tree_queue.get_node(_SCOPE, "m2") + assert root is not None and root.state is MessageState.ERROR + assert child is not None and child.state is MessageState.ERROR + rendered = "\n".join( + call.args[2] for call in mock_platform.queue_edit_message.call_args_list + ) + assert "Parent task failed" in rendered + + +@pytest.mark.asyncio +async def test_different_trees_process_independently( + handler_integration, + mock_platform, + mock_cli_manager, + incoming_message_factory, +) -> None: + session_one = MagicMock() + session_one.start_task.return_value = _events([{"type": "exit", "code": 0}]) + session_two = MagicMock() + session_two.start_task.return_value = _events([{"type": "exit", "code": 0}]) + mock_cli_manager.get_or_create_session.side_effect = [ + (session_one, "s1", False), + (session_two, "s2", False), + ] + mock_platform.queue_send_message = AsyncMock(side_effect=["status-t1", "status-t2"]) + + await asyncio.gather( + handler_integration.handle_message( + incoming_message_factory(text="t1", message_id="t1") + ), + handler_integration.handle_message( + incoming_message_factory(text="t2", message_id="t2") + ), + ) + await _wait_for_idle(handler_integration) + + node_one = await handler_integration.tree_queue.get_node(_SCOPE, "t1") + node_two = await handler_integration.tree_queue.get_node(_SCOPE, "t2") + assert node_one is not None and node_one.state is MessageState.COMPLETED + assert node_two is not None and node_two.state is MessageState.COMPLETED diff --git a/tests/messaging/test_handler_markdown_and_status_edges.py b/tests/messaging/test_handler_markdown_and_status_edges.py new file mode 100644 index 0000000..48156cb --- /dev/null +++ b/tests/messaging/test_handler_markdown_and_status_edges.py @@ -0,0 +1,544 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.messaging.command_context import StopOutcome +from free_claude_code.messaging.models import IncomingMessage, MessageScope +from free_claude_code.messaging.node_event_pipeline import process_parsed_cli_event +from free_claude_code.messaging.rendering.telegram_markdown import ( + render_markdown_to_mdv2, +) +from free_claude_code.messaging.trees import ( + CancellationReason, + CancellationResult, + CancellationUiOwner, + FailureResult, + MessageReferenceKind, + NodeClaim, + NodeUiTarget, + QueueDecision, + QueueEntry, + ReplyTarget, + TreeIdentity, + TreeSnapshot, +) +from free_claude_code.messaging.trees.transitions import CancellationEffect +from free_claude_code.messaging.workflow import MessagingWorkflow + +_SCOPE = MessageScope(platform="telegram", chat_id="c") + + +def _claim() -> NodeClaim: + return NodeClaim( + identity=TreeIdentity(scope=_SCOPE, root_id="root"), + claim_id="claim-1", + node=NodeUiTarget( + scope=_SCOPE, + node_id="n1", + status_message_id="s1", + ), + prompt="hi", + parent_session_id=None, + ) + + +def _snapshot(marker: str) -> TreeSnapshot: + return TreeSnapshot( + scope=_SCOPE, + root_id="root", + nodes={"root": {"marker": marker}}, + ) + + +def _decision(*, position: int | None = None) -> QueueDecision: + return QueueDecision( + claim=_claim() if position is None else None, + position=position, + snapshot=_snapshot("admitted"), + ) + + +def test_render_markdown_to_mdv2_empty_returns_empty(): + assert render_markdown_to_mdv2("") == "" + + +def test_render_markdown_to_mdv2_covers_common_structures(): + md = ( + "# Heading\n\n" + "Text with *em* and **strong** and ~~strike~~ and `code`.\n\n" + "- item1\n" + "- item2\n\n" + "3. third\n\n" + "> quote\n\n" + "[link](http://example.com/a\\)b)\n\n" + "![alt](http://example.com/img.png)\n\n" + "```python\nprint('x')\n```\n" + ) + out = render_markdown_to_mdv2(md) + assert "*Heading*" in out + assert "_em_" in out + assert "*strong*" in out + assert "~strike~" in out + assert "`code`" in out + assert "\\- item1" in out + assert "3\\." in out + assert "> quote" in out + assert "[link]" in out + assert "alt (http://example.com/img.png)" in out + assert "```" in out + + +def test_render_markdown_to_mdv2_renders_table_as_code_block(): + md = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n\nAfter.\n" + out = render_markdown_to_mdv2(md) + assert "```" in out + assert "| a" in out + assert "| b" in out + assert "| ---" in out + assert "After" in out + + +def test_render_markdown_to_mdv2_table_without_blank_line_still_renders(): + md = "Here's a table:\n| a | b |\n|---|---|\n| 1 | 2 |\n" + out = render_markdown_to_mdv2(md) + assert "Here's a table" in out + assert "```" in out + assert "| a" in out + assert "| ---" in out + + +def test_render_markdown_to_mdv2_table_escapes_backticks_and_backslashes_in_cells(): + md = "| a | b |\n|---|---|\n| \\\\ | `` ` `` |\n" + out = render_markdown_to_mdv2(md) + assert "```" in out + # In Telegram code blocks we escape backslashes and backticks. + assert "\\\\" in out # rendered cell backslash becomes double-backslash + assert "\\`" in out # rendered cell backtick is escaped + + +def test_render_markdown_to_mdv2_table_inside_list_keeps_bullet_prefix(): + md = "-\n | a | b |\n |---|---|\n | 1 | 2 |\n" + out = render_markdown_to_mdv2(md) + assert "```" in out + assert out.lstrip().startswith("\\-") + assert out.find("\\-") < out.find("```") + + +def test_get_initial_status_branches(): + platform = MagicMock() + cli_manager = MagicMock() + session_store = MagicMock() + handler = MessagingWorkflow(platform, cli_manager, session_store) + + s1 = handler.turn_intake._get_initial_status( + ReplyTarget( + node_id="p", + reference_id="p", + reference_kind=MessageReferenceKind.PROMPT, + queue_position=3, + ) + ) + assert "Queued" in s1 + assert "position 3" in s1 or "position 3" in s1.replace("\\", "") + + s2 = handler.turn_intake._get_initial_status( + ReplyTarget( + node_id="p", + reference_id="status-p", + reference_kind=MessageReferenceKind.STATUS, + queue_position=None, + ) + ) + assert "Continuing" in s2 + + s3 = handler.turn_intake._get_initial_status(None) + assert "Launching" in s3 + + +@pytest.mark.asyncio +async def test_update_queue_positions_renders_immutable_queue_entries(): + platform = MagicMock() + platform.queue_edit_message = AsyncMock() + platform.fire_and_forget = MagicMock( + side_effect=lambda c: getattr(c, "close", lambda: None)() + ) + + cli_manager = MagicMock() + session_store = MagicMock() + handler = MessagingWorkflow(platform, cli_manager, session_store) + + await handler.turn_intake.update_queue_positions(()) + platform.fire_and_forget.assert_not_called() + + await handler.turn_intake.update_queue_positions( + ( + QueueEntry( + node=NodeUiTarget( + scope=_SCOPE, + node_id="n1", + status_message_id="s", + ), + position=2, + ), + ) + ) + assert platform.fire_and_forget.call_count == 1 + assert "position 2" in platform.queue_edit_message.call_args.args[2] + + +@pytest.mark.asyncio +async def test_node_runner_process_node_session_limit_marks_error_and_updates_ui(): + platform = MagicMock() + platform.queue_edit_message = AsyncMock() + platform.fire_and_forget = MagicMock( + side_effect=lambda c: getattr(c, "close", lambda: None)() + ) + + cli_manager = MagicMock() + cli_manager.get_or_create_session = AsyncMock(side_effect=RuntimeError("limit")) + cli_manager.get_stats.return_value = {"active_sessions": 0} + + session_store = MagicMock() + handler = MessagingWorkflow(platform, cli_manager, session_store) + + claim = _claim() + snapshot = _snapshot("error") + fail_claim = AsyncMock( + return_value=FailureResult(affected=(), queue_update=None, snapshot=snapshot) + ) + with patch.object( + handler.tree_queue, + "fail_claim", + fail_claim, + ): + await handler.node_runner.process_node(claim) + assert platform.queue_edit_message.await_count >= 1 + fail_claim.assert_awaited_once_with( + claim, + propagate=False, + ) + session_store.save_tree_snapshot.assert_called_once_with(snapshot) + + +@pytest.mark.asyncio +async def test_node_runner_cancellation_marks_error_and_saves_tree(): + platform = MagicMock() + platform.queue_edit_message = AsyncMock() + platform.fire_and_forget = MagicMock( + side_effect=lambda c: getattr(c, "close", lambda: None)() + ) + + async def _cancelled_start_task(*args, **kwargs): + raise asyncio.CancelledError + yield + + mock_session = MagicMock() + mock_session.start_task = _cancelled_start_task + cli_manager = MagicMock() + cli_manager.get_or_create_session = AsyncMock( + return_value=(mock_session, "s1", False) + ) + cli_manager.remove_session = AsyncMock() + cli_manager.get_stats.return_value = {"active_sessions": 0} + + session_store = MagicMock() + handler = MessagingWorkflow(platform, cli_manager, session_store) + + claim = _claim() + snapshot = _snapshot("cancelled") + fail_claim = AsyncMock( + return_value=FailureResult(affected=(), queue_update=None, snapshot=snapshot) + ) + with patch.object( + handler.tree_queue, + "fail_claim", + fail_claim, + ): + await handler.node_runner.process_node(claim) + + fail_claim.assert_awaited_once_with( + claim, + propagate=False, + ) + session_store.save_tree_snapshot.assert_called_once_with(snapshot) + + +@pytest.mark.asyncio +async def test_stop_all_tasks_saves_tree_for_cancelled_nodes(): + platform = MagicMock() + platform.queue_edit_message = AsyncMock() + platform.fire_and_forget = MagicMock( + side_effect=lambda c: getattr(c, "close", lambda: None)() + ) + + cli_manager = MagicMock() + cli_manager.stop_all = AsyncMock() + cli_manager.get_stats.return_value = {"active_sessions": 0} + + session_store = MagicMock() + handler = MessagingWorkflow(platform, cli_manager, session_store) + + snapshot = _snapshot("ok") + result = CancellationResult( + effects=( + CancellationEffect( + node=_claim().node, + ui_owner=CancellationUiOwner.RUNNER, + ), + ), + snapshots=(snapshot,), + ) + cancel_all = AsyncMock(return_value=result) + with patch.object( + handler.tree_queue, + "cancel_all", + cancel_all, + ): + outcome = await handler.stop_all_tasks() + assert outcome == StopOutcome( + cancelled_count=1, + status_feedback_scopes=frozenset({_SCOPE}), + fallback_required=False, + ) + cancel_all.assert_awaited_once_with(reason=CancellationReason.STOP) + cli_manager.stop_all.assert_awaited_once() + session_store.save_tree_snapshot.assert_called_once_with(snapshot) + + +@pytest.mark.asyncio +async def test_handle_message_unresolved_reply_is_admitted_as_new(): + platform = MagicMock() + platform.queue_send_message = AsyncMock(return_value="status_1") + platform.queue_edit_message = AsyncMock() + + cli_manager = MagicMock() + cli_manager.get_stats.return_value = {"active_sessions": 0} + + session_store = MagicMock() + handler = MessagingWorkflow(platform, cli_manager, session_store) + + resolve_reply = AsyncMock(return_value=None) + admit = AsyncMock(return_value=_decision()) + + incoming = IncomingMessage( + text="reply", + chat_id="c", + user_id="u", + message_id="m1", + platform="telegram", + reply_to_message_id="some_reply", + ) + + with ( + patch.object(handler.tree_queue, "resolve_reply", resolve_reply), + patch.object(handler.tree_queue, "admit", admit), + ): + await handler.handle_message(incoming) + + resolve_reply.assert_awaited_once_with(incoming.scope, "some_reply") + admit.assert_awaited_once_with( + incoming, + "status_1", + parent_reference_id=None, + ) + + +@pytest.mark.asyncio +async def test_update_ui_handles_transcript_render_exception(): + """When transcript.render raises, update_ui catches and does not crash.""" + platform = MagicMock() + platform.queue_edit_message = AsyncMock() + platform.fire_and_forget = MagicMock( + side_effect=lambda c: getattr(c, "close", lambda: None)() + ) + + cli_manager = MagicMock() + session_store = MagicMock() + + async def _mock_start_task(*args, **kwargs): + yield { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "hi"}, + } + yield {"type": "complete", "status": "success"} + + mock_session = MagicMock() + mock_session.start_task = _mock_start_task + cli_manager.get_or_create_session = AsyncMock( + return_value=(mock_session, "s1", False) + ) + cli_manager.remove_session = AsyncMock() + cli_manager.get_stats.return_value = {"active_sessions": 0} + + handler = MessagingWorkflow(platform, cli_manager, session_store) + claim = _claim() + snapshot = _snapshot("complete") + + with ( + patch.object( + handler.node_runner, "_create_transcript_and_render_ctx" + ) as mock_create, + patch.object( + handler.tree_queue, + "complete_claim", + AsyncMock(return_value=snapshot), + ), + ): + transcript = MagicMock() + transcript.render = MagicMock(side_effect=ValueError("render failed")) + render_ctx = MagicMock() + mock_create.return_value = (transcript, render_ctx) + + await handler.node_runner.process_node(claim) + + assert transcript.render.call_count >= 1 + + +@pytest.mark.asyncio +async def test_handle_message_incoming_text_none_safe(): + """handle_message does not crash when incoming.text is None (e.g. malformed adapter).""" + platform = MagicMock() + platform.queue_send_message = AsyncMock(return_value="status_1") + platform.queue_edit_message = AsyncMock() + + cli_manager = MagicMock() + cli_manager.get_stats.return_value = {"active_sessions": 0} + + session_store = MagicMock() + handler = MessagingWorkflow(platform, cli_manager, session_store) + admit = AsyncMock(return_value=_decision()) + + incoming = MagicMock() + incoming.text = None + incoming.chat_id = "c" + incoming.user_id = "u" + incoming.message_id = "m1" + incoming.platform = "telegram" + incoming.reply_to_message_id = None + incoming.status_message_id = None + incoming.message_thread_id = None + incoming.is_reply = MagicMock(return_value=False) + + with patch.object(handler.tree_queue, "admit", admit): + await handler.handle_message(incoming) + admit.assert_awaited_once_with( + incoming, + "status_1", + parent_reference_id=None, + ) + + +@pytest.mark.asyncio +async def test_process_parsed_event_malformed_content_continues(): + """Malformed/unknown parsed event does not crash process_parsed_cli_event.""" + platform = MagicMock() + platform.queue_edit_message = AsyncMock() + + cli_manager = MagicMock() + session_store = MagicMock() + handler = MessagingWorkflow(platform, cli_manager, session_store) + + transcript = MagicMock() + update_ui = AsyncMock() + complete_claim = AsyncMock() + fail_claim = AsyncMock() + + last_status, had = await process_parsed_cli_event( + parsed={"type": "unknown_type"}, + transcript=transcript, + update_ui=update_ui, + last_status=None, + had_transcript_events=False, + claim=_claim(), + captured_session_id=None, + format_status=handler.format_status, + complete_claim=complete_claim, + fail_claim=fail_claim, + ) + assert last_status is None + assert had is False + complete_claim.assert_not_awaited() + fail_claim.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_process_parsed_event_failed_complete_does_not_mark_success(): + """Failed terminal events are not rendered as successful completion.""" + platform = MagicMock() + platform.queue_edit_message = AsyncMock() + + cli_manager = MagicMock() + session_store = MagicMock() + handler = MessagingWorkflow(platform, cli_manager, session_store) + + transcript = MagicMock() + update_ui = AsyncMock() + complete_claim = AsyncMock() + fail_claim = AsyncMock() + + last_status, had = await process_parsed_cli_event( + parsed={"type": "complete", "status": "failed"}, + transcript=transcript, + update_ui=update_ui, + last_status="❌ Error", + had_transcript_events=True, + claim=_claim(), + captured_session_id="session_1", + format_status=handler.format_status, + complete_claim=complete_claim, + fail_claim=fail_claim, + ) + + assert last_status == "❌ Error" + assert had is True + update_ui.assert_not_awaited() + complete_claim.assert_not_awaited() + fail_claim.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_handler_update_ui_edit_failure_does_not_crash(): + """When queue_edit_message raises during streaming, node_runner.process_node continues and completes.""" + platform = MagicMock() + platform.queue_edit_message = AsyncMock( + side_effect=RuntimeError("Telegram API error") + ) + platform.fire_and_forget = MagicMock( + side_effect=lambda c: getattr(c, "close", lambda: None)() + ) + + async def _mock_start_task(*args, **kwargs): + yield { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "Hello"}, + } + yield { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": " world"}, + } + yield {"type": "complete", "status": "success"} + + mock_session = MagicMock() + mock_session.start_task = _mock_start_task + cli_manager = MagicMock() + cli_manager.get_or_create_session = AsyncMock( + return_value=(mock_session, "s1", False) + ) + cli_manager.remove_session = AsyncMock() + cli_manager.get_stats.return_value = {"active_sessions": 0} + + session_store = MagicMock() + handler = MessagingWorkflow(platform, cli_manager, session_store) + snapshot = _snapshot("complete") + with patch.object( + handler.tree_queue, + "complete_claim", + AsyncMock(return_value=snapshot), + ): + await handler.node_runner.process_node(_claim()) + + cli_manager.remove_session.assert_awaited_once() diff --git a/tests/messaging/test_limiter.py b/tests/messaging/test_limiter.py new file mode 100644 index 0000000..3ed42f6 --- /dev/null +++ b/tests/messaging/test_limiter.py @@ -0,0 +1,431 @@ +import asyncio +import contextlib +import time +from collections.abc import Callable + +import pytest +import pytest_asyncio + +from free_claude_code.messaging.limiter import MessagingRateLimiter + + +class TestMessagingRateLimiter: + """Tests for MessagingRateLimiter.""" + + @pytest_asyncio.fixture(autouse=True) + async def limiter_factory(self): + """Build started limiters and stop every instance after the test.""" + instances: list[MessagingRateLimiter] = [] + + def create( + *, rate_limit: int = 1, rate_window: float = 1.0 + ) -> MessagingRateLimiter: + limiter = MessagingRateLimiter( + rate_limit=rate_limit, + rate_window=rate_window, + ) + limiter.start() + instances.append(limiter) + return limiter + + self.create_limiter: Callable[..., MessagingRateLimiter] = create + yield + for limiter in reversed(instances): + await limiter.shutdown(timeout=0.1) + + @pytest.mark.asyncio + async def test_instances_are_independent(self): + """Each messaging runtime receives independent limiter state.""" + limiter1 = self.create_limiter(rate_limit=1, rate_window=0.5) + limiter2 = self.create_limiter(rate_limit=99, rate_window=99.0) + + assert limiter1 is not limiter2 + assert limiter1.limiter._rate_limit == 1 + assert limiter1.limiter._rate_window == 0.5 + assert limiter2.limiter._rate_limit == 99 + assert limiter2.limiter._rate_window == 99.0 + + await limiter1.shutdown(timeout=0.1) + + async def succeed() -> str: + return "still running" + + assert await limiter2.enqueue(succeed) == "still running" + + @pytest.mark.asyncio + async def test_start_is_required_and_shutdown_is_idempotent(self): + limiter = MessagingRateLimiter(rate_limit=1, rate_window=1.0) + + async def succeed() -> str: + return "ok" + + with pytest.raises(RuntimeError, match="has not been started"): + await limiter.enqueue(succeed) + + limiter.start() + limiter.start() + assert await limiter.enqueue(succeed) == "ok" + await limiter.shutdown(timeout=0.1) + await limiter.shutdown(timeout=0.1) + + with pytest.raises(RuntimeError, match="is closed"): + await limiter.enqueue(succeed) + + @pytest.mark.asyncio + async def test_compaction(self): + """ + Verify multiple rapid requests with same dedup_key are compacted. + Logic ported from verify_limiter.py + """ + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + + call_counts = {} + + async def mock_edit(msg_id, content): + call_counts[msg_id] = call_counts.get(msg_id, 0) + 1 + return f"done_{content}" + + # Spam 5 edits + for i in range(5): + limiter.fire_and_forget( + lambda i=i: mock_edit("msg1", f"update_{i}"), dedup_key="edit:msg1" + ) + + # Wait for processing + # 1st might go through immediately, subsequent ones queue and compact + await asyncio.sleep(2.5) + + # Expected: ~2 calls (first and last) + assert call_counts["msg1"] <= 2, ( + f"Expected compaction to reduce calls, but got {call_counts.get('msg1', 0)}" + ) + assert call_counts["msg1"] >= 1, "Expected at least one call" + + @pytest.mark.asyncio + async def test_compaction_and_futures_resolution(self): + """ + Verify that even when compacted, all futures resolve to the result of the LAST execution. + Logic ported from verify_limiter_v2.py + """ + limiter = self.create_limiter(rate_limit=1, rate_window=0.5) + + call_counts = {} + msg_id = "test_msg_hang" + + async def mock_edit(mid, content): + call_counts[mid] = call_counts.get(mid, 0) + 1 + await asyncio.sleep(0.05) + return f"result_{content}" + + async def task(i): + return await limiter.enqueue( + lambda i=i: mock_edit(msg_id, f"v{i}"), dedup_key=f"edit:{msg_id}" + ) + + start_time = time.time() + + # Enqueue 3 tasks concurrently + results = await asyncio.gather(task(1), task(2), task(3)) + + duration = time.time() - start_time + + # All results should be the LAST one executed + for res in results: + assert res == "result_v3", f"Expected result_v3, got {res}" + + # Should be reasonably fast + assert duration < 2.0, "Execution took too long" + + # Calls should be compacted + assert call_counts[msg_id] <= 2, f"Too many actual calls: {call_counts[msg_id]}" + + @pytest.mark.asyncio + async def test_flood_wait_handling(self): + """Test that FloodWait exceptions pause the worker.""" + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + + # Mock exception with .seconds attribute + class FloodWait(Exception): + def __init__(self, seconds): + self.seconds = seconds + super().__init__(f"Flood wait {seconds}s") + + call_count = 0 + + async def mock_fail(): + nonlocal call_count + call_count += 1 + raise FloodWait(1) # 1 second wait + + async def mock_success(): + nonlocal call_count + call_count += 1 + return "success" + + # First call fails and triggers pause + with contextlib.suppress(Exception): + await limiter.enqueue(mock_fail, dedup_key="key1") + + assert limiter._paused_until > 0 + + # Enqueue success, it should wait + start = time.time() + await limiter.enqueue(mock_success, dedup_key="key2") + duration = time.time() - start + + # Should have waited at least ~1s + assert duration >= 0.9, ( + f"Should have waited for FloodWait, but took {duration:.2f}s" + ) + assert call_count == 2 + + @pytest.mark.asyncio + async def test_flood_wait_retry_after_parsing(self): + """Error message with 'retry after N' parses the wait seconds.""" + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + + async def mock_flood(): + raise Exception("Flood wait: retry after 2 seconds") + + with contextlib.suppress(Exception): + await limiter.enqueue(mock_flood, dedup_key="retry_parse") + + # Should have parsed "after 2" -> 2 seconds + assert limiter._paused_until > 0 + + @pytest.mark.asyncio + async def test_non_flood_exception_no_pause(self): + """Non-flood exception doesn't trigger pause.""" + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + + async def mock_error(): + raise ValueError("some regular error") + + with contextlib.suppress(ValueError): + await limiter.enqueue(mock_error, dedup_key="non_flood") + + # Should NOT have paused since it's not a flood error + assert limiter._paused_until == 0 + + @pytest.mark.asyncio + async def test_flood_with_seconds_attribute(self): + """Exception with .seconds attribute uses that value for pause.""" + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + + class FloodWaitCustom(Exception): + def __init__(self): + self.seconds = 2 + super().__init__("Flood wait custom") + + async def mock_flood(): + raise FloodWaitCustom() + + with contextlib.suppress(Exception): + await limiter.enqueue(mock_flood, dedup_key="flood_sec") + + assert limiter._paused_until > 0 + + @pytest.mark.asyncio + async def test_proactive_strict_sliding_window(self): + """ + Proactive limiter should enforce a strict sliding window: + for any i, t[i+rate_limit] - t[i] >= rate_window (within tolerance). + """ + limiter = self.create_limiter(rate_limit=2, rate_window=0.5) + + async def acquire(i: int) -> float: + async def _do() -> float: + return time.monotonic() + + return await limiter.enqueue(_do, dedup_key=f"strict:{i}") + + acquired = await asyncio.gather(*(acquire(i) for i in range(5))) + acquired.sort() + + rate_limit = 2 + rate_window = 0.5 + tolerance = 0.05 + for i in range(len(acquired) - rate_limit): + assert acquired[i + rate_limit] - acquired[i] >= rate_window - tolerance, ( + f"Sliding window violated at i={i}: " + f"dt={acquired[i + rate_limit] - acquired[i]:.3f}s" + ) + + @pytest.mark.asyncio + async def test_compaction_last_task_fails_all_futures_get_exception(self): + """When compacted task's last func fails, all futures get the exception.""" + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + + async def ok_task(): + return "ok" + + async def fail_task(): + raise RuntimeError("last task failed") + + future1 = asyncio.create_task(limiter.enqueue(ok_task, dedup_key="fail_key")) + future2 = asyncio.create_task(limiter.enqueue(fail_task, dedup_key="fail_key")) + + with pytest.raises(RuntimeError, match="last task failed"): + await future1 + with pytest.raises(RuntimeError, match="last task failed"): + await future2 + + @pytest.mark.asyncio + async def test_fire_and_forget_failure_logged(self, caplog): + """fire_and_forget with failing task logs error and does not re-raise.""" + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + + async def fail_task(): + raise ValueError("fire_and_forget failed") + + limiter.fire_and_forget(fail_task, dedup_key="fire_fail") + await asyncio.sleep(1.5) + + joined = " ".join(str(r.message) for r in caplog.records) + assert "ValueError" in joined + assert "fire_and_forget failed" not in joined + + @pytest.mark.asyncio + async def test_shutdown_settles_active_queued_and_background_work(self): + limiter = self.create_limiter(rate_limit=1, rate_window=60.0) + active_started = asyncio.Event() + never_finish = asyncio.Event() + + async def active_operation() -> None: + active_started.set() + await never_finish.wait() + + active = asyncio.create_task( + limiter.enqueue(active_operation, dedup_key="active") + ) + await active_started.wait() + + async def queued_operation() -> None: + await never_finish.wait() + + queued = asyncio.create_task( + limiter.enqueue(queued_operation, dedup_key="queued") + ) + limiter.fire_and_forget(queued_operation, dedup_key="background") + await asyncio.sleep(0) + + await limiter.shutdown(timeout=0.1) + results = await asyncio.gather(active, queued, return_exceptions=True) + + assert all(isinstance(result, asyncio.CancelledError) for result in results) + assert limiter._background_tasks == set() + assert limiter._queue_map == {} + assert not limiter._queue_list + + @pytest.mark.asyncio + async def test_enqueue_cannot_enter_after_shutdown_begins(self): + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + await limiter._condition.acquire() + + async def succeed() -> str: + return "unexpected" + + enqueue_task = asyncio.create_task( + limiter.enqueue(succeed, dedup_key="shutdown-race") + ) + await asyncio.sleep(0) + shutdown_task = asyncio.create_task(limiter.shutdown(timeout=0.1)) + await asyncio.sleep(0) + assert limiter._closed is True + + limiter._condition.release() + await shutdown_task + + with pytest.raises(RuntimeError, match="is closed"): + await enqueue_task + + @pytest.mark.asyncio + async def test_shutdown_preserves_external_cancellation(self): + limiter = self.create_limiter(rate_limit=1, rate_window=1.0) + release = asyncio.Event() + + async def cancellation_resistant_worker() -> None: + try: + await release.wait() + except asyncio.CancelledError: + await release.wait() + + worker_task = limiter._worker_task + assert worker_task is not None + await asyncio.sleep(0) + worker_task.cancel() + await asyncio.gather(worker_task, return_exceptions=True) + limiter._worker_task = asyncio.create_task(cancellation_resistant_worker()) + shutdown_task = asyncio.create_task(limiter.shutdown(timeout=1.0)) + await asyncio.sleep(0) + + shutdown_task.cancel() + release.set() + + with pytest.raises(asyncio.CancelledError): + await shutdown_task + + @pytest.mark.asyncio + async def test_cancelled_shutdown_retries_queued_future_settlement(self): + limiter = self.create_limiter(rate_limit=1, rate_window=60.0) + active_started = asyncio.Event() + never_finish = asyncio.Event() + + async def active_operation() -> None: + active_started.set() + await never_finish.wait() + + active = asyncio.create_task( + limiter.enqueue(active_operation, dedup_key="active") + ) + await active_started.wait() + + async def queued_operation() -> None: + await never_finish.wait() + + queued = asyncio.create_task( + limiter.enqueue(queued_operation, dedup_key="queued") + ) + while "queued" not in limiter._queue_map: + await asyncio.sleep(0) + + await limiter._condition.acquire() + shutdown_task = asyncio.create_task(limiter.shutdown()) + await asyncio.sleep(0) + assert limiter._closed is True + + shutdown_task.cancel() + with pytest.raises(asyncio.CancelledError): + await shutdown_task + limiter._condition.release() + + await limiter.shutdown(timeout=0.1) + results = await asyncio.gather(active, queued, return_exceptions=True) + + assert all(isinstance(result, asyncio.CancelledError) for result in results) + assert limiter._queue_map == {} + assert not limiter._queue_list + assert limiter._worker_task is None + + @pytest.mark.asyncio + async def test_cancelled_operation_does_not_stop_owned_worker(self): + limiter = self.create_limiter(rate_limit=2, rate_window=1.0) + + async def cancelled_operation() -> None: + raise asyncio.CancelledError + + async def successful_operation() -> str: + return "delivered" + + first = asyncio.create_task( + limiter.enqueue(cancelled_operation, dedup_key="cancelled") + ) + second = asyncio.create_task( + limiter.enqueue(successful_operation, dedup_key="next") + ) + results = await asyncio.gather(first, second, return_exceptions=True) + + assert isinstance(results[0], asyncio.CancelledError) + assert results[1] == "delivered" + assert limiter._worker_task is not None + assert not limiter._worker_task.done() diff --git a/tests/messaging/test_message_tree_transitions.py b/tests/messaging/test_message_tree_transitions.py new file mode 100644 index 0000000..6b0a0b5 --- /dev/null +++ b/tests/messaging/test_message_tree_transitions.py @@ -0,0 +1,258 @@ +import asyncio +from dataclasses import FrozenInstanceError + +import pytest + +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.trees.node import MessageNode, MessageState +from free_claude_code.messaging.trees.runtime import MessageTree + +_SCOPE = MessageScope(platform="telegram", chat_id="chat") + + +def _tree() -> MessageTree: + return MessageTree( + MessageNode( + node_id="root", + scope=_SCOPE, + prompt="prompt root", + status_message_id="status-root", + ) + ) + + +async def _add( + tree: MessageTree, + node_id: str, + status_message_id: str, + parent_id: str = "root", + parent_reference_id: str | None = None, +): + return await tree.add_and_enqueue( + node_id, + _SCOPE, + f"prompt {node_id}", + status_message_id, + parent_id, + parent_reference_id or parent_id, + ) + + +@pytest.mark.asyncio +async def test_enqueue_finish_and_claim_next_are_atomic_and_fifo() -> None: + tree = _tree() + + root = await tree.enqueue_or_claim("root") + child_a = await _add(tree, "child-a", "status-a") + child_b = await _add(tree, "child-b", "status-b") + + assert root.accepted and root.claim is not None and root.position is None + assert child_a.claim is None and child_a.position == 1 + assert child_b.claim is None and child_b.position == 2 + + completion = await tree.finish_and_claim_next(root.claim.claim_id) + + assert completion.next_claim is not None + assert completion.next_claim.node.node_id == "child-a" + assert [(entry.node.node_id, entry.position) for entry in completion.queue] == [ + ("child-b", 1) + ] + + +@pytest.mark.asyncio +async def test_duplicate_unknown_and_terminal_admission_are_rejected_without_wedge() -> ( + None +): + tree = _tree() + + first = await tree.enqueue_or_claim("root") + duplicate = await tree.enqueue_or_claim("root") + unknown = await tree.enqueue_or_claim("status-root") + assert first.claim is not None + assert duplicate.accepted is False + assert unknown.accepted is False + assert duplicate.snapshot is None + assert unknown.snapshot is None + + await tree.complete_claim(first.claim.claim_id, "session") + await tree.finish_and_claim_next(first.claim.claim_id) + terminal = await tree.enqueue_or_claim("root") + assert terminal.accepted is False + assert terminal.snapshot is None + + +@pytest.mark.asyncio +async def test_stale_claim_id_cannot_clear_a_newer_claim() -> None: + tree = _tree() + first = await tree.enqueue_or_claim("root") + assert first.claim is not None + await tree.finish_and_claim_next(first.claim.claim_id) + + second = await _add(tree, "child", "status-child") + assert second.claim is not None + + stale = await tree.finish_and_claim_next(first.claim.claim_id) + assert stale.next_claim is None + assert await tree.record_session(second.claim.claim_id, "session") is not None + await tree.finish_and_claim_next(second.claim.claim_id) + third = await _add(tree, "third", "status-third") + assert third.claim is not None + + +@pytest.mark.asyncio +async def test_cancellation_keeps_active_identity_until_matching_finish() -> None: + tree = _tree() + root = await tree.enqueue_or_claim("root") + queued = await _add(tree, "queued", "status-queued") + assert root.claim is not None and queued.position == 1 + + cancelled = await tree.cancel_node("root") + + assert cancelled.active_claim == root.claim + assert [node.node_id for node in cancelled.nodes] == ["root"] + + completion = await tree.finish_and_claim_next(root.claim.claim_id) + assert completion.next_claim is not None + assert completion.next_claim.node.node_id == "queued" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("propagate", [False, True]) +async def test_cancelled_claim_rejects_late_failure_and_descendant_propagation( + propagate: bool, +) -> None: + tree = _tree() + root = await tree.enqueue_or_claim("root") + queued = await _add(tree, "queued", "status-queued") + assert root.claim is not None and queued.position == 1 + + await tree.cancel_node("root") + late_failure = await tree.fail_claim( + root.claim.claim_id, + propagate=propagate, + ) + + assert late_failure.snapshot is None + assert late_failure.affected == () + assert late_failure.queue_update is None + queued_view = await tree.node_view("queued") + assert queued_view is not None + assert queued_view.state is MessageState.PENDING + completion = await tree.finish_and_claim_next(root.claim.claim_id) + assert completion.next_claim is not None + assert completion.next_claim.node.node_id == "queued" + + +@pytest.mark.asyncio +async def test_cancelled_queue_member_is_never_claimed() -> None: + tree = _tree() + root = await tree.enqueue_or_claim("root") + await _add(tree, "a", "status-a") + await _add(tree, "b", "status-b") + assert root.claim is not None + + cancelled = await tree.cancel_node("a") + assert [node.node_id for node in cancelled.nodes] == ["a"] + + completion = await tree.finish_and_claim_next(root.claim.claim_id) + assert completion.next_claim is not None + assert completion.next_claim.node.node_id == "b" + + +@pytest.mark.asyncio +async def test_prompt_subtree_removal_returns_every_platform_message() -> None: + tree = _tree() + await _add(tree, "child", "status-child") + + removed = await tree.remove_message_subtree("root") + + assert removed.removed_message_ids == frozenset( + {"root", "status-root", "child", "status-child"} + ) + + +@pytest.mark.asyncio +async def test_status_subtree_preserves_prompt_siblings_and_invalidates_resume() -> ( + None +): + tree = _tree() + root = await tree.enqueue_or_claim("root") + assert root.claim is not None + await tree.record_session(root.claim.claim_id, "session-root") + direct_sibling = await _add(tree, "sibling", "status-sibling") + status_descendant = await _add( + tree, + "descendant", + "status-descendant", + parent_reference_id="status-root", + ) + assert direct_sibling.position == 1 + assert status_descendant.position == 2 + + removed = await tree.remove_message_subtree("status-root") + + assert removed.removed_message_ids == frozenset( + {"status-root", "descendant", "status-descendant"} + ) + root_view = await tree.node_view("root") + sibling_view = await tree.node_view("sibling") + assert root_view is not None + assert root_view.state is MessageState.ERROR + assert root_view.session_id is None + assert sibling_view is not None + assert sibling_view.state is MessageState.PENDING + assert await tree.node_view("descendant") is None + assert await tree.resolve_reply("status-root") is None + + completion = await tree.finish_and_claim_next(root.claim.claim_id) + assert completion.next_claim is not None + assert completion.next_claim.node.node_id == "sibling" + assert completion.next_claim.parent_session_id is None + + +@pytest.mark.asyncio +async def test_concurrent_duplicate_enqueue_produces_one_claim_only() -> None: + tree = _tree() + gate = asyncio.Event() + + async def enqueue(): + await gate.wait() + return await tree.enqueue_or_claim("root") + + tasks = [asyncio.create_task(enqueue()) for _ in range(20)] + gate.set() + results = await asyncio.gather(*tasks) + + assert sum(result.claim is not None for result in results) == 1 + assert sum(result.accepted for result in results) == 1 + + +@pytest.mark.asyncio +async def test_results_are_frozen_and_do_not_expose_mutable_nodes() -> None: + tree = _tree() + decision = await tree.enqueue_or_claim("root") + assert decision.claim is not None + + with pytest.raises(FrozenInstanceError): + decision.claim.__setattr__("claim_id", "replacement") + assert not hasattr(decision.claim, "__dict__") + assert not isinstance(decision.claim.node, MessageNode) + + +def test_message_tree_has_no_lock_or_partial_mutation_escape_hatches() -> None: + forbidden = { + "with_lock", + "enqueue", + "dequeue", + "put_queue_unlocked", + "remove_from_queue", + "set_processing_state", + "clear_current_node", + "set_current_task", + "cancel_current_task", + "set_node_error_sync", + "drain_queue_and_mark_cancelled", + "reset_processing_state", + } + + assert forbidden.isdisjoint(vars(MessageTree)) diff --git a/tests/messaging/test_messaging.py b/tests/messaging/test_messaging.py new file mode 100644 index 0000000..b97e804 --- /dev/null +++ b/tests/messaging/test_messaging.py @@ -0,0 +1,241 @@ +"""Tests for messaging/ module.""" + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# --- Existing Tests --- + + +class TestMessagingModels: + """Test messaging models.""" + + def test_incoming_message_creation(self): + """Test IncomingMessage dataclass.""" + from free_claude_code.messaging.models import IncomingMessage + + msg = IncomingMessage( + text="Hello", + chat_id="123", + user_id="456", + message_id="789", + platform="telegram", + ) + assert msg.text == "Hello" + assert msg.chat_id == "123" + assert msg.platform == "telegram" + assert msg.is_reply() is False + + def test_incoming_message_with_reply(self): + """Test IncomingMessage as a reply.""" + from free_claude_code.messaging.models import IncomingMessage + + msg = IncomingMessage( + text="Reply text", + chat_id="123", + user_id="456", + message_id="789", + platform="discord", + reply_to_message_id="100", + ) + assert msg.is_reply() is True + assert msg.reply_to_message_id == "100" + + +class TestMessagingPorts: + """Test explicit messaging platform component ports.""" + + def test_components_bundle_runtime_and_outbound(self): + """Verify the factory handoff shape is explicit.""" + from free_claude_code.messaging.platforms.ports import ( + MessagingPlatformComponents, + ) + + runtime = MagicMock() + runtime.name = "telegram" + runtime.start = AsyncMock() + runtime.quiesce = AsyncMock() + runtime.close = AsyncMock() + runtime.on_message = MagicMock() + outbound = MagicMock() + outbound.queue_send_message = AsyncMock() + outbound.queue_edit_message = AsyncMock() + outbound.queue_delete_messages = AsyncMock() + outbound.fire_and_forget = MagicMock() + components = MessagingPlatformComponents( + name="telegram", + runtime=runtime, + outbound=outbound, + voice_cancellation=None, + ) + assert components.runtime is runtime + assert components.outbound is outbound + + +class TestSessionStore: + """Test SessionStore.""" + + def test_session_store_init(self, tmp_path): + """Test SessionStore initialization.""" + from free_claude_code.messaging.session import SessionStore + + store = SessionStore(storage_path=str(tmp_path / "sessions.json")) + assert store.load_conversation_snapshot().is_empty + + # --- Tree Tests --- + + def test_save_and_get_tree(self, tmp_path): + """Test saving and retrieving trees.""" + from free_claude_code.messaging.models import MessageScope + from free_claude_code.messaging.session import SessionStore + from free_claude_code.messaging.trees import TreeIdentity, TreeSnapshot + + store = SessionStore(storage_path=str(tmp_path / "sessions.json")) + scope = MessageScope(platform="telegram", chat_id="chat") + + tree_data = { + "scope": {"platform": scope.platform, "chat_id": scope.chat_id}, + "root_id": "r1", + "nodes": { + "r1": {"node_id": "r1", "status_message_id": "s1"}, + "n1": {"node_id": "n1", "status_message_id": "s2"}, + }, + } + snapshot = TreeSnapshot.from_json(tree_data) + assert snapshot is not None + store.save_tree_snapshot(snapshot) + + identity = TreeIdentity(scope=scope, root_id="r1") + loaded = store.load_conversation_snapshot().get_tree(identity) + assert loaded is not None + assert loaded == snapshot + assert loaded.lookup_ids() == {"r1", "s1", "n1", "s2"} + + # --- Persistence & Edge Cases --- + + def test_load_existing_file_with_trees(self, tmp_path): + """Test loading file with trees (legacy sessions ignored).""" + from free_claude_code.messaging.models import MessageScope + from free_claude_code.messaging.session import SessionStore + from free_claude_code.messaging.trees import TreeIdentity + + data = { + "sessions": {}, + "trees": { + "r1": { + "root_id": "r1", + "nodes": { + "r1": { + "node_id": "r1", + "incoming": { + "platform": "telegram", + "chat_id": "chat", + }, + } + }, + } + }, + "node_to_tree": {"r1": "r1"}, + "message_log": {}, + } + + p = tmp_path / "sessions.json" + with open(p, "w") as f: + json.dump(data, f) + + store = SessionStore(storage_path=str(p)) + identity = TreeIdentity( + scope=MessageScope(platform="telegram", chat_id="chat"), + root_id="r1", + ) + assert store.load_conversation_snapshot().get_tree(identity) is not None + + def test_load_corrupt_file(self, tmp_path): + """Test loading corrupt/invalid json file.""" + p = tmp_path / "sessions.json" + with open(p, "w") as f: + f.write("{invalid json") + + from free_claude_code.messaging.session import SessionStore + + # Should log error and start empty, avoiding crash + store = SessionStore(storage_path=str(p)) + assert store.load_conversation_snapshot().is_empty + + def test_save_error_handling(self, tmp_path): + """Test error during save.""" + from free_claude_code.messaging.models import MessageScope + from free_claude_code.messaging.session import SessionStore + from free_claude_code.messaging.trees import TreeIdentity, TreeSnapshot + + store = SessionStore(storage_path=str(tmp_path / "sessions.json")) + scope = MessageScope(platform="telegram", chat_id="chat") + snapshot = TreeSnapshot(scope=scope, root_id="r1", nodes={"r1": {}}) + store.save_tree_snapshot(snapshot) + + with ( + patch( + "free_claude_code.messaging.session.persistence.os.replace", + side_effect=OSError("Disk full"), + ), + pytest.raises(OSError, match="Disk full"), + ): + store.flush_pending_save() + + assert store.dirty is True + identity = TreeIdentity(scope=scope, root_id="r1") + assert store.load_conversation_snapshot().get_tree(identity) is not None + + +class TestTreeQueueManager: + """Test TreeQueueManager.""" + + def test_tree_queue_manager_init(self): + from free_claude_code.messaging.trees import TreeQueueManager + + async def process(_claim): + return None + + mgr = TreeQueueManager(process) + assert mgr.get_tree_count() == 0 + + @pytest.mark.asyncio + async def test_admit_creates_tree_and_claim(self): + from free_claude_code.messaging.models import IncomingMessage + from free_claude_code.messaging.trees import TreeQueueManager + + processed = asyncio.Event() + + async def processor(claim): + assert claim.node.node_id == "1" + processed.set() + + incoming = IncomingMessage( + text="test", + chat_id="1", + user_id="1", + message_id="1", + platform="test", + ) + + mgr = TreeQueueManager(processor) + decision = await mgr.admit(incoming, "status_1") + + assert decision.accepted is True + assert decision.claim is not None + await processed.wait() + + @pytest.mark.asyncio + async def test_cancel_unknown_node_is_empty(self): + from free_claude_code.messaging.models import MessageScope + from free_claude_code.messaging.trees import TreeQueueManager + + async def process(_claim): + return None + + mgr = TreeQueueManager(process) + scope = MessageScope(platform="test", chat_id="1") + cancelled = await mgr.cancel_node(scope, "nonexistent") + assert cancelled.effects == () diff --git a/tests/messaging/test_messaging_factory.py b/tests/messaging/test_messaging_factory.py new file mode 100644 index 0000000..bc813e3 --- /dev/null +++ b/tests/messaging/test_messaging_factory.py @@ -0,0 +1,191 @@ +"""Tests for messaging platform factory.""" + +from unittest.mock import MagicMock, patch + +from free_claude_code.messaging.platforms.factory import ( + MessagingPlatformOptions, + create_messaging_components, +) +from free_claude_code.messaging.platforms.ports import MessagingStartupNotice + + +class TestCreateMessagingComponents: + """Tests for create_messaging_components factory function.""" + + def test_telegram_with_token(self): + """Create Telegram platform when bot_token is provided.""" + mock_runtime = MagicMock() + mock_runtime.name = "telegram" + mock_runtime.outbound = MagicMock() + limiter = MagicMock() + transcriber = MagicMock() + with ( + patch( + "free_claude_code.messaging.platforms.factory.MessagingRateLimiter", + return_value=limiter, + ) as limiter_cls, + patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ), + patch( + "free_claude_code.messaging.platforms.telegram.TelegramRuntime", + return_value=mock_runtime, + ) as runtime_cls, + ): + result = create_messaging_components( + "telegram", + MessagingPlatformOptions( + telegram_bot_token="test_token", + allowed_telegram_user_id="12345", + telegram_proxy_url="socks5://127.0.0.1:1080", + transcriber=transcriber, + messaging_rate_limit=7, + messaging_rate_window=2.5, + ), + ) + + assert result is not None + assert result.runtime is mock_runtime + assert result.outbound is mock_runtime.outbound + assert result.voice_cancellation is mock_runtime + assert result.startup_notice == MessagingStartupNotice( + chat_id="12345", + transport_label="Bot API", + ) + limiter_cls.assert_called_once_with( + rate_limit=7, + rate_window=2.5, + log_error_details=False, + ) + runtime_cls.assert_called_once_with( + bot_token="test_token", + allowed_user_id="12345", + telegram_proxy_url="socks5://127.0.0.1:1080", + limiter=limiter, + transcriber=transcriber, + log_raw_messaging_content=False, + log_api_error_tracebacks=False, + ) + + def test_telegram_without_token(self): + """Return None when no bot_token for Telegram.""" + result = create_messaging_components("telegram") + assert result is None + + def test_telegram_empty_token(self): + """Return None when bot_token is empty string.""" + result = create_messaging_components( + "telegram", MessagingPlatformOptions(telegram_bot_token="") + ) + assert result is None + + def test_discord_with_token(self): + """Create Discord platform when discord_bot_token is provided.""" + mock_runtime = MagicMock() + mock_runtime.name = "discord" + mock_runtime.outbound = MagicMock() + limiter = MagicMock() + transcriber = MagicMock() + with ( + patch( + "free_claude_code.messaging.platforms.factory.MessagingRateLimiter", + return_value=limiter, + ) as limiter_cls, + patch( + "free_claude_code.messaging.platforms.discord.DISCORD_AVAILABLE", True + ), + patch( + "free_claude_code.messaging.platforms.discord.DiscordRuntime", + return_value=mock_runtime, + ) as runtime_cls, + ): + result = create_messaging_components( + "discord", + MessagingPlatformOptions( + discord_bot_token="test_token", + allowed_discord_channels="123,456", + transcriber=transcriber, + messaging_rate_limit=3, + messaging_rate_window=4.5, + ), + ) + + assert result is not None + assert result.runtime is mock_runtime + assert result.outbound is mock_runtime.outbound + assert result.voice_cancellation is mock_runtime + assert result.startup_notice is None + limiter_cls.assert_called_once_with( + rate_limit=3, + rate_window=4.5, + log_error_details=False, + ) + runtime_cls.assert_called_once_with( + bot_token="test_token", + allowed_channel_ids="123,456", + limiter=limiter, + transcriber=transcriber, + log_raw_messaging_content=False, + log_api_error_tracebacks=False, + ) + + def test_discord_without_token(self): + """Return None when no discord_bot_token for Discord.""" + result = create_messaging_components("discord") + assert result is None + + def test_discord_empty_token(self): + """Return None when discord_bot_token is empty string.""" + result = create_messaging_components( + "discord", + MessagingPlatformOptions( + discord_bot_token="", + allowed_discord_channels="123", + ), + ) + assert result is None + + def test_unknown_platform(self): + """Return None for unknown platform types.""" + result = create_messaging_components("slack") + assert result is None + + def test_unknown_platform_with_kwargs(self): + """Return None for unknown platform even with kwargs.""" + result = create_messaging_components( + "slack", MessagingPlatformOptions(telegram_bot_token="token") + ) + assert result is None + + def test_separate_factory_calls_construct_distinct_limiters(self): + """Each selected platform runtime owns a new limiter instance.""" + runtime = MagicMock(name="runtime") + runtime.name = "telegram" + runtime.outbound = MagicMock() + with ( + patch( + "free_claude_code.messaging.platforms.telegram.TelegramRuntime", + return_value=runtime, + ) as runtime_cls, + patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ), + ): + first = create_messaging_components( + "telegram", + MessagingPlatformOptions(telegram_bot_token="one"), + ) + second = create_messaging_components( + "telegram", + MessagingPlatformOptions(telegram_bot_token="two"), + ) + + assert first is not None + assert second is not None + assert first.startup_notice is None + assert second.startup_notice is None + first_limiter = runtime_cls.call_args_list[0].kwargs["limiter"] + second_limiter = runtime_cls.call_args_list[1].kwargs["limiter"] + assert first_limiter is not second_limiter + assert runtime_cls.call_args_list[0].kwargs["transcriber"] is None + assert runtime_cls.call_args_list[1].kwargs["transcriber"] is None diff --git a/tests/messaging/test_platform_outbox.py b/tests/messaging/test_platform_outbox.py new file mode 100644 index 0000000..c582cdb --- /dev/null +++ b/tests/messaging/test_platform_outbox.py @@ -0,0 +1,186 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.messaging.platforms.outbox import PlatformOutbox + + +def _noop_outbox(*, limiter=None, delete_many=None) -> PlatformOutbox: + async def send( + chat_id: str, + text: str, + reply_to: str | None, + parse_mode: str | None, + message_thread_id: str | None, + ) -> str: + return f"{chat_id}:{text}:{reply_to}:{parse_mode}:{message_thread_id}" + + async def edit( + chat_id: str, + message_id: str, + text: str, + parse_mode: str | None, + ) -> None: + return None + + async def default_delete_many(chat_id: str, message_ids: list[str]) -> None: + return None + + return PlatformOutbox( + limiter=limiter or MagicMock(), + send=send, + edit=edit, + delete_many=delete_many or default_delete_many, + ) + + +@pytest.mark.asyncio +async def test_queue_send_awaits_required_limiter() -> None: + limiter = MagicMock() + + async def enqueue(operation, dedup_key=None): + return await operation() + + limiter.enqueue = AsyncMock(side_effect=enqueue) + outbox = _noop_outbox(limiter=limiter) + + result = await outbox.queue_send_message( + "chat", + "hello", + reply_to="reply", + parse_mode="MarkdownV2", + fire_and_forget=False, + message_thread_id="thread", + ) + + assert result == "chat:hello:reply:MarkdownV2:thread" + limiter.enqueue.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_queue_edit_awaits_limiter_with_dedup_key() -> None: + limiter = MagicMock() + limiter.enqueue = AsyncMock() + outbox = _noop_outbox(limiter=limiter) + + await outbox.queue_edit_message( + "chat", + "message", + "updated", + parse_mode="MarkdownV2", + fire_and_forget=False, + ) + + limiter.enqueue.assert_awaited_once() + operation = limiter.enqueue.call_args.args[0] + assert limiter.enqueue.call_args.kwargs["dedup_key"] == "edit:chat:message" + await operation() + + +@pytest.mark.asyncio +async def test_queue_delete_many_skips_empty_batches() -> None: + limiter = MagicMock() + outbox = _noop_outbox(limiter=limiter) + + await outbox.queue_delete_messages("chat", [], fire_and_forget=True) + + limiter.fire_and_forget.assert_not_called() + + +@pytest.mark.asyncio +async def test_queue_delete_many_dedups_by_batch() -> None: + limiter = MagicMock() + outbox = _noop_outbox(limiter=limiter) + + await outbox.queue_delete_messages("chat", ["1", "2"], fire_and_forget=True) + + limiter.fire_and_forget.assert_called_once() + assert ( + limiter.fire_and_forget.call_args.kwargs["dedup_key"] + == "del_bulk:chat:11f0530a8259fffb" + ) + + +@pytest.mark.asyncio +async def test_queue_delete_many_snapshots_ids_before_queueing() -> None: + limiter = MagicMock() + deleted: list[list[str]] = [] + + async def delete_many(_chat_id: str, message_ids: list[str]) -> None: + deleted.append(message_ids) + + outbox = _noop_outbox(limiter=limiter, delete_many=delete_many) + message_ids = ["1", "2"] + + await outbox.queue_delete_messages("chat", message_ids, fire_and_forget=True) + message_ids.append("3") + operation = limiter.fire_and_forget.call_args.args[0] + await operation() + + assert deleted == [["1", "2"]] + + +@pytest.mark.asyncio +async def test_close_cancels_and_settles_owned_background_work() -> None: + outbox = _noop_outbox() + started = asyncio.Event() + cancelled = asyncio.Event() + + async def pending() -> None: + started.set() + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + outbox.fire_and_forget(pending()) + await started.wait() + + await outbox.close() + + assert cancelled.is_set() + assert outbox._background_tasks == set() + + +@pytest.mark.asyncio +async def test_completed_background_failure_is_observed_and_released() -> None: + outbox = _noop_outbox() + + async def fail() -> None: + raise RuntimeError("background failed") + + with patch("free_claude_code.messaging.platforms.outbox.logger.error") as error_log: + outbox.fire_and_forget(fail()) + await asyncio.sleep(0) + await asyncio.sleep(0) + + assert outbox._background_tasks == set() + error_log.assert_called_once_with( + "Outbound background task failed: exc_type={}", + "RuntimeError", + ) + + +@pytest.mark.asyncio +async def test_close_rejects_all_later_work() -> None: + limiter = MagicMock() + outbox = _noop_outbox(limiter=limiter) + await outbox.close() + + with pytest.raises(RuntimeError, match="outbox is closed"): + await outbox.queue_send_message("chat", "message") + + ran = False + + async def late_task() -> None: + nonlocal ran + ran = True + + with pytest.raises(RuntimeError, match="outbox is closed"): + outbox.fire_and_forget(late_task()) + await asyncio.sleep(0) + + assert ran is False + assert outbox._background_tasks == set() + limiter.fire_and_forget.assert_not_called() diff --git a/tests/messaging/test_platform_voice_flow.py b/tests/messaging/test_platform_voice_flow.py new file mode 100644 index 0000000..40ee494 --- /dev/null +++ b/tests/messaging/test_platform_voice_flow.py @@ -0,0 +1,927 @@ +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.platforms.voice_flow import ( + VOICE_DISABLED_MESSAGE, + VOICE_TRANSCRIPTION_ERROR_MESSAGE, + VoiceNoteFlow, + VoiceNoteRequest, + audio_suffix_from_metadata, + is_audio_metadata, +) +from free_claude_code.messaging.trees.runtime import MessageTree +from free_claude_code.messaging.voice import Transcriber +from free_claude_code.messaging.workflow import MessagingWorkflow + +VOICE_SCOPE = MessageScope(platform="telegram", chat_id="chat") + + +class FatalVoiceError(BaseException): + """Fatal sentinel used to verify ownership finalization.""" + + +class MockTranscriber: + def __init__(self, result: str = "hello from voice") -> None: + self.run = AsyncMock(return_value=result) + self.close_run = AsyncMock() + self.paths: list[Path] = [] + + async def transcribe(self, file_path: Path) -> str: + self.paths.append(file_path) + return await self.run(file_path) + + async def close(self) -> None: + await self.close_run() + + +def _flow(*, enabled: bool = True) -> tuple[VoiceNoteFlow, MockTranscriber]: + transcriber = MockTranscriber() + configured: Transcriber | None = transcriber if enabled else None + return ( + VoiceNoteFlow( + transcriber=configured, + log_raw_messaging_content=False, + log_api_error_tracebacks=False, + ), + transcriber, + ) + + +def _request( + *, + download_to=None, + reply_text=None, + message_id: str = "voice", +) -> VoiceNoteRequest: + async def default_download_to(path: Path) -> None: + path.write_bytes(b"voice") + + return VoiceNoteRequest( + platform="telegram", + chat_id="chat", + user_id="user", + message_id=message_id, + raw_event={"raw": True}, + content_type="audio/ogg", + temp_suffix=".ogg", + status_text="transcribing", + status_parse_mode="MarkdownV2", + message_thread_id="thread", + reply_to_message_id="reply", + download_to=download_to or default_download_to, + reply_text=reply_text or AsyncMock(), + ) + + +@pytest.mark.asyncio +async def test_voice_flow_success_builds_incoming_message() -> None: + flow, transcriber = _flow() + handler = AsyncMock() + queue_send = AsyncMock(return_value="status") + queue_delete = AsyncMock() + downloaded_paths: list[Path] = [] + + async def download_to(path: Path) -> None: + downloaded_paths.append(path) + path.write_bytes(b"voice") + + handled = await flow.handle( + _request(download_to=download_to), + message_handler=handler, + queue_send_message=queue_send, + queue_delete_messages=queue_delete, + ) + + assert handled is True + queue_send.assert_awaited_once_with( + "chat", + "transcribing", + reply_to="voice", + parse_mode="MarkdownV2", + fire_and_forget=False, + message_thread_id="thread", + ) + queue_delete.assert_not_awaited() + handler.assert_awaited_once() + incoming = handler.call_args.args[0] + assert incoming.text == "hello from voice" + assert incoming.chat_id == "chat" + assert incoming.message_id == "voice" + assert incoming.reply_to_message_id == "reply" + assert incoming.message_thread_id == "thread" + assert incoming.status_message_id == "status" + transcriber.run.assert_awaited_once() + assert transcriber.paths == downloaded_paths + assert downloaded_paths and not downloaded_paths[0].exists() + + +@pytest.mark.asyncio +async def test_voice_flow_disabled_replies_without_transcribing() -> None: + flow, transcriber = _flow(enabled=False) + reply_text = AsyncMock() + + handled = await flow.handle( + _request(reply_text=reply_text), + message_handler=AsyncMock(), + queue_send_message=AsyncMock(), + queue_delete_messages=AsyncMock(), + ) + + assert handled is True + reply_text.assert_awaited_once_with(VOICE_DISABLED_MESSAGE) + transcriber.run.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_voice_flow_missing_status_id_stops_before_transcription() -> None: + flow, transcriber = _flow() + reply_text = AsyncMock() + handler = AsyncMock() + queue_delete = AsyncMock() + + handled = await flow.handle( + _request(reply_text=reply_text), + message_handler=handler, + queue_send_message=AsyncMock(return_value=None), + queue_delete_messages=queue_delete, + ) + + assert handled is True + transcriber.run.assert_not_awaited() + handler.assert_not_awaited() + queue_delete.assert_not_awaited() + reply_text.assert_awaited_once_with(VOICE_TRANSCRIPTION_ERROR_MESSAGE) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +async def test_voice_flow_cancelled_transcription_preserves_owned_status() -> None: + flow, transcriber = _flow() + + async def canceling_transcribe(_path: Path) -> str: + await flow.cancel_pending_voice(VOICE_SCOPE, "voice") + return "ignored" + + transcriber.run.side_effect = canceling_transcribe + handler = AsyncMock() + queue_send = AsyncMock(return_value="status") + queue_delete = AsyncMock() + + handled = await flow.handle( + _request(), + message_handler=handler, + queue_send_message=queue_send, + queue_delete_messages=queue_delete, + ) + + assert handled is True + handler.assert_not_awaited() + queue_delete.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_voice_flow_cancel_during_status_delivery_prevents_transcription() -> ( + None +): + flow, transcriber = _flow() + status_send_started = asyncio.Event() + release_status_send = asyncio.Event() + handler = AsyncMock() + queue_delete = AsyncMock() + + async def send_status(*_args, **_kwargs) -> str: + status_send_started.set() + await release_status_send.wait() + return "status" + + handle_task = asyncio.create_task( + flow.handle( + _request(), + message_handler=handler, + queue_send_message=send_status, + queue_delete_messages=queue_delete, + ) + ) + + try: + await asyncio.wait_for(status_send_started.wait(), timeout=1) + cancelled = await flow.cancel_pending_voice(VOICE_SCOPE, "voice") + + assert cancelled is not None + assert cancelled.voice_message_id == "voice" + assert cancelled.status_message_id is None + + release_status_send.set() + assert await asyncio.wait_for(handle_task, timeout=1) is True + finally: + release_status_send.set() + if not handle_task.done(): + handle_task.cancel() + with pytest.raises(asyncio.CancelledError): + await handle_task + + transcriber.run.assert_not_awaited() + handler.assert_not_awaited() + queue_delete.assert_awaited_once_with("chat", ["status"]) + + +@pytest.mark.asyncio +async def test_voice_flow_cancel_during_status_delivery_suppresses_late_failure() -> ( + None +): + flow, transcriber = _flow() + status_send_started = asyncio.Event() + release_status_send = asyncio.Event() + reply_text = AsyncMock() + handler = AsyncMock() + + async def fail_status_send(*_args, **_kwargs) -> str: + status_send_started.set() + await release_status_send.wait() + raise RuntimeError("late status failure") + + handle_task = asyncio.create_task( + flow.handle( + _request(reply_text=reply_text), + message_handler=handler, + queue_send_message=fail_status_send, + queue_delete_messages=AsyncMock(), + ) + ) + + try: + await asyncio.wait_for(status_send_started.wait(), timeout=1) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is not None + + release_status_send.set() + assert await asyncio.wait_for(handle_task, timeout=1) is True + finally: + release_status_send.set() + if not handle_task.done(): + handle_task.cancel() + with pytest.raises(asyncio.CancelledError): + await handle_task + + transcriber.run.assert_not_awaited() + handler.assert_not_awaited() + reply_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_voice_flow_cancel_during_transcription_suppresses_late_failure() -> None: + flow, transcriber = _flow() + transcription_started = asyncio.Event() + release_transcription = asyncio.Event() + reply_text = AsyncMock() + handler = AsyncMock() + queue_delete = AsyncMock() + + async def fail_transcription(_path: Path) -> str: + transcription_started.set() + await release_transcription.wait() + raise RuntimeError("late transcription failure") + + transcriber.run.side_effect = fail_transcription + handle_task = asyncio.create_task( + flow.handle( + _request(reply_text=reply_text), + message_handler=handler, + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + ) + + try: + await asyncio.wait_for(transcription_started.wait(), timeout=1) + cancelled = await flow.cancel_pending_voice(VOICE_SCOPE, "voice") + assert cancelled is not None + assert cancelled.status_message_id == "status" + + release_transcription.set() + assert await asyncio.wait_for(handle_task, timeout=1) is True + finally: + release_transcription.set() + if not handle_task.done(): + handle_task.cancel() + with pytest.raises(asyncio.CancelledError): + await handle_task + + handler.assert_not_awaited() + reply_text.assert_not_awaited() + queue_delete.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reply_stop_status_survives_late_transcription_success( + mock_platform, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +) -> None: + flow, transcriber = _flow() + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + mock_session_store, + platform_name="telegram", + voice_cancellation=flow, + ) + transcription_started = asyncio.Event() + release_transcription = asyncio.Event() + + async def delayed_transcription(_path: Path) -> str: + transcription_started.set() + await release_transcription.wait() + return "late voice prompt" + + transcriber.run.side_effect = delayed_transcription + mock_platform.queue_send_message.return_value = "voice_status" + voice_task = asyncio.create_task( + flow.handle( + _request(), + message_handler=workflow.handle_message, + queue_send_message=mock_platform.queue_send_message, + queue_delete_messages=mock_platform.queue_delete_messages, + ) + ) + await asyncio.wait_for(transcription_started.wait(), timeout=1) + + try: + await workflow.handle_message( + incoming_message_factory( + text="/stop", + chat_id="chat", + message_id="stop_command", + reply_to_message_id="voice", + ) + ) + finally: + release_transcription.set() + assert await asyncio.wait_for(voice_task, timeout=1) is True + await asyncio.sleep(0) + + stopped = workflow.format_status("⏹", "Stopped.") + assert any( + call.args[:3] == ("chat", "voice_status", stopped) + for call in mock_platform.queue_edit_message.await_args_list + ) + mock_platform.queue_delete_messages.assert_not_awaited() + assert await workflow.tree_queue.resolve_node_id(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +async def test_voice_flow_cancel_during_handoff_stops_and_drains_handler() -> None: + flow, _transcriber = _flow() + handler_started = asyncio.Event() + handler_cancelled = asyncio.Event() + release_handler = asyncio.Event() + queue_delete = AsyncMock() + + async def handler(_incoming) -> None: + handler_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + handler_cancelled.set() + await release_handler.wait() + raise + + handle_task = asyncio.create_task( + flow.handle( + _request(), + message_handler=handler, + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + ) + + try: + await asyncio.wait_for(handler_started.wait(), timeout=1) + cancel_task = asyncio.create_task( + flow.cancel_pending_voice(VOICE_SCOPE, "voice") + ) + await asyncio.wait_for(handler_cancelled.wait(), timeout=1) + + assert not cancel_task.done() + release_handler.set() + cancelled = await asyncio.wait_for(cancel_task, timeout=1) + assert cancelled is not None + assert cancelled.status_message_id == "status" + assert await asyncio.wait_for(handle_task, timeout=1) is True + finally: + release_handler.set() + if not handle_task.done(): + handle_task.cancel() + with pytest.raises(asyncio.CancelledError): + await handle_task + + queue_delete.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_voice_flow_cancel_during_handoff_suppresses_late_handler_error() -> None: + flow, _transcriber = _flow() + handler_started = asyncio.Event() + reply_text = AsyncMock() + + async def handler(_incoming) -> None: + handler_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + raise RuntimeError("late handler failure") from None + + handle_task = asyncio.create_task( + flow.handle( + _request(reply_text=reply_text), + message_handler=handler, + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=AsyncMock(), + ) + ) + + await asyncio.wait_for(handler_started.wait(), timeout=1) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "status") is not None + assert await asyncio.wait_for(handle_task, timeout=1) is True + reply_text.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("command", ["/stop", "/clear"]) +async def test_reply_command_cancels_voice_at_tree_admission_commit( + command: str, + monkeypatch: pytest.MonkeyPatch, + mock_platform, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +) -> None: + flow, _transcriber = _flow() + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + mock_session_store, + platform_name="telegram", + voice_cancellation=flow, + ) + admission_mutated = asyncio.Event() + release_admission = asyncio.Event() + original_enqueue_or_claim = MessageTree.enqueue_or_claim + + async def mutate_then_block(tree: MessageTree, node_id: str): + decision = await original_enqueue_or_claim(tree, node_id) + if node_id == "voice": + admission_mutated.set() + await release_admission.wait() + return decision + + monkeypatch.setattr(MessageTree, "enqueue_or_claim", mutate_then_block) + mock_platform.queue_send_message.return_value = "voice_status" + voice_task = asyncio.create_task( + flow.handle( + _request(), + message_handler=workflow.handle_message, + queue_send_message=mock_platform.queue_send_message, + queue_delete_messages=mock_platform.queue_delete_messages, + ) + ) + await asyncio.wait_for(admission_mutated.wait(), timeout=1) + + command_task = asyncio.create_task( + workflow.handle_message( + incoming_message_factory( + text=command, + chat_id="chat", + message_id="command", + reply_to_message_id="voice", + ) + ) + ) + try: + await asyncio.sleep(0) + assert not command_task.done() + finally: + release_admission.set() + await asyncio.wait_for(command_task, timeout=1) + voice_result = await asyncio.wait_for(voice_task, timeout=1) + assert voice_result is True + await asyncio.sleep(0) + + mock_session_store.save_tree_snapshot.assert_called() + if command == "/clear": + assert workflow.get_tree_count() == 0 + assert await workflow.tree_queue.resolve_node_id(VOICE_SCOPE, "voice") is None + else: + assert workflow.get_tree_count() == 1 + assert ( + await workflow.tree_queue.resolve_node_id(VOICE_SCOPE, "voice") == "voice" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("command", ["/stop", "/clear"]) +async def test_global_command_rejects_transcription_that_finishes_late( + command: str, + mock_platform, + mock_cli_manager, + mock_session_store, + incoming_message_factory, +) -> None: + flow, transcriber = _flow() + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + mock_session_store, + platform_name="telegram", + voice_cancellation=flow, + ) + transcription_started = asyncio.Event() + release_transcription = asyncio.Event() + + async def delayed_transcription(_path: Path) -> str: + transcription_started.set() + await release_transcription.wait() + return "late voice prompt" + + transcriber.run.side_effect = delayed_transcription + mock_platform.queue_send_message.return_value = "voice_status" + voice_task = asyncio.create_task( + flow.handle( + _request(), + message_handler=workflow.handle_message, + queue_send_message=mock_platform.queue_send_message, + queue_delete_messages=mock_platform.queue_delete_messages, + ) + ) + await asyncio.wait_for(transcription_started.wait(), timeout=1) + + await asyncio.wait_for( + workflow.handle_message( + incoming_message_factory( + text=command, + message_id="command", + chat_id=VOICE_SCOPE.chat_id, + ) + ), + timeout=1, + ) + release_transcription.set() + assert await asyncio.wait_for(voice_task, timeout=1) is True + + assert workflow.get_tree_count() == 0 + assert await workflow.tree_queue.resolve_node_id(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("command", ["/stop", "/clear"]) +async def test_transcribed_global_command_does_not_cancel_its_own_handoff( + command: str, + mock_platform, + mock_cli_manager, + mock_session_store, +) -> None: + flow, transcriber = _flow() + transcriber.run.return_value = command + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + mock_session_store, + platform_name="telegram", + voice_cancellation=flow, + ) + mock_platform.queue_send_message.return_value = "voice_status" + + assert ( + await asyncio.wait_for( + flow.handle( + _request(), + message_handler=workflow.handle_message, + queue_send_message=mock_platform.queue_send_message, + queue_delete_messages=mock_platform.queue_delete_messages, + ), + timeout=1, + ) + is True + ) + + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +async def test_voice_flow_caller_cancellation_drains_handoff_child() -> None: + flow, _transcriber = _flow() + handler_started = asyncio.Event() + handler_cancelled = asyncio.Event() + release_handler = asyncio.Event() + queue_delete = AsyncMock() + + async def handler(_incoming) -> None: + handler_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + handler_cancelled.set() + await release_handler.wait() + raise + + handle_task = asyncio.create_task( + flow.handle( + _request(), + message_handler=handler, + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + ) + await asyncio.wait_for(handler_started.wait(), timeout=1) + handle_task.cancel() + await asyncio.wait_for(handler_cancelled.wait(), timeout=1) + + assert not handle_task.done() + release_handler.set() + with pytest.raises(asyncio.CancelledError): + await handle_task + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + queue_delete.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_voice_flow_task_cancellation_waits_then_cleans_pending_state() -> None: + flow, transcriber = _flow() + started = asyncio.Event() + cancellation_received = asyncio.Event() + release = asyncio.Event() + stopped = asyncio.Event() + + async def cancellation_safe_transcribe(_path: Path) -> str: + started.set() + try: + await asyncio.Event().wait() + return "unreachable" + except asyncio.CancelledError: + cancellation_received.set() + await release.wait() + stopped.set() + raise + + transcriber.run.side_effect = cancellation_safe_transcribe + handler = AsyncMock() + queue_delete = AsyncMock() + handle_task = asyncio.create_task( + flow.handle( + _request(), + message_handler=handler, + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + ) + + await started.wait() + handle_task.cancel() + await cancellation_received.wait() + + assert not handle_task.done() + queue_delete.assert_not_awaited() + + release.set() + with pytest.raises(asyncio.CancelledError): + await handle_task + + assert stopped.is_set() + handler.assert_not_awaited() + queue_delete.assert_awaited_once_with("chat", ["status"]) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +async def test_voice_flow_repeated_cancellation_cannot_interrupt_cleanup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + flow, transcriber = _flow() + transcription_started = asyncio.Event() + cleanup_started = asyncio.Event() + release_cleanup = asyncio.Event() + + async def blocked_transcription(_path: Path) -> str: + transcription_started.set() + await asyncio.Event().wait() + return "unreachable" + + original_discard = flow._pending_voice.discard + + async def delayed_discard(claim) -> bool: + cleanup_started.set() + await release_cleanup.wait() + return await original_discard(claim) + + transcriber.run.side_effect = blocked_transcription + monkeypatch.setattr(flow._pending_voice, "discard", delayed_discard) + queue_delete = AsyncMock() + handle_task = asyncio.create_task( + flow.handle( + _request(), + message_handler=AsyncMock(), + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + ) + + await asyncio.wait_for(transcription_started.wait(), timeout=1) + handle_task.cancel("first cancellation") + await asyncio.wait_for(cleanup_started.wait(), timeout=1) + handle_task.cancel("second cancellation") + await asyncio.sleep(0) + + assert not handle_task.done() + release_cleanup.set() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(handle_task, timeout=1) + + queue_delete.assert_awaited_once_with("chat", ["status"]) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + assert await flow.cancel_pending_voice(VOICE_SCOPE, "status") is None + + +@pytest.mark.asyncio +async def test_voice_flow_fatal_status_failure_releases_claim() -> None: + flow, transcriber = _flow() + queue_delete = AsyncMock() + + async def fatal_status(*_args, **_kwargs) -> str: + raise FatalVoiceError + + with pytest.raises(FatalVoiceError): + await flow.handle( + _request(), + message_handler=AsyncMock(), + queue_send_message=fatal_status, + queue_delete_messages=queue_delete, + ) + + transcriber.run.assert_not_awaited() + queue_delete.assert_not_awaited() + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +async def test_voice_flow_fatal_download_failure_releases_status_ownership() -> None: + flow, transcriber = _flow() + queue_delete = AsyncMock() + + async def fatal_download(_path: Path) -> None: + raise FatalVoiceError + + with pytest.raises(FatalVoiceError): + await flow.handle( + _request(download_to=fatal_download), + message_handler=AsyncMock(), + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + + transcriber.run.assert_not_awaited() + queue_delete.assert_awaited_once_with("chat", ["status"]) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + assert await flow.cancel_pending_voice(VOICE_SCOPE, "status") is None + + +@pytest.mark.asyncio +async def test_voice_flow_fatal_transcription_releases_status_ownership() -> None: + flow, transcriber = _flow() + transcriber.run.side_effect = FatalVoiceError() + handler = AsyncMock() + queue_delete = AsyncMock() + + with pytest.raises(FatalVoiceError): + await flow.handle( + _request(), + message_handler=handler, + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + + handler.assert_not_awaited() + queue_delete.assert_awaited_once_with("chat", ["status"]) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + assert await flow.cancel_pending_voice(VOICE_SCOPE, "status") is None + + +@pytest.mark.asyncio +async def test_voice_flow_download_failure_cleans_pending_state() -> None: + flow, transcriber = _flow() + reply_text = AsyncMock() + queue_delete = AsyncMock() + + async def failing_download(_path: Path) -> None: + raise RuntimeError("download failed") + + handled = await flow.handle( + _request(download_to=failing_download, reply_text=reply_text), + message_handler=AsyncMock(), + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + + assert handled is True + transcriber.run.assert_not_awaited() + queue_delete.assert_awaited_once_with("chat", ["status"]) + reply_text.assert_awaited_once_with(VOICE_TRANSCRIPTION_ERROR_MESSAGE) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +async def test_voice_flow_transcription_failure_cleans_pending_state() -> None: + flow, transcriber = _flow() + transcriber.run.side_effect = RuntimeError("transcription failed") + reply_text = AsyncMock() + queue_delete = AsyncMock() + + handled = await flow.handle( + _request(reply_text=reply_text), + message_handler=AsyncMock(), + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + + assert handled is True + queue_delete.assert_awaited_once_with("chat", ["status"]) + reply_text.assert_awaited_once_with(VOICE_TRANSCRIPTION_ERROR_MESSAGE) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +async def test_voice_flow_handler_failure_cleans_pending_without_deleting_status() -> ( + None +): + flow, _transcriber = _flow() + reply_text = AsyncMock() + queue_delete = AsyncMock() + + async def failing_handler(_incoming) -> None: + raise RuntimeError("handler failed") + + handled = await flow.handle( + _request(reply_text=reply_text), + message_handler=failing_handler, + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + + assert handled is True + queue_delete.assert_not_awaited() + reply_text.assert_awaited_once_with(VOICE_TRANSCRIPTION_ERROR_MESSAGE) + assert await flow.cancel_pending_voice(VOICE_SCOPE, "voice") is None + + +@pytest.mark.asyncio +async def test_voice_flow_rejects_oversized_audio_before_transcription( + monkeypatch, +) -> None: + monkeypatch.setattr( + "free_claude_code.messaging.platforms.voice_flow.MAX_AUDIO_SIZE_BYTES", + 3, + ) + flow, transcriber = _flow() + reply_text = AsyncMock() + queue_delete = AsyncMock() + + async def download(path: Path) -> None: + path.write_bytes(b"four") + + handled = await flow.handle( + _request(download_to=download, reply_text=reply_text), + message_handler=AsyncMock(), + queue_send_message=AsyncMock(return_value="status"), + queue_delete_messages=queue_delete, + ) + + assert handled is True + transcriber.run.assert_not_awaited() + queue_delete.assert_awaited_once_with("chat", ["status"]) + assert reply_text.await_args is not None + assert "too large" in reply_text.await_args.args[0] + + +def test_audio_metadata_helpers() -> None: + assert is_audio_metadata("voice.ogg", "application/octet-stream") is True + assert is_audio_metadata("file.txt", "audio/ogg") is True + assert is_audio_metadata("file.txt", "text/plain") is False + assert ( + audio_suffix_from_metadata(filename="voice.ogg", content_type="audio/mp4") + == ".mp4" + ) + assert ( + audio_suffix_from_metadata(filename="clip.m4a", content_type="audio/mp4") + == ".m4a" + ) + assert ( + audio_suffix_from_metadata(filename="clip.m4a", content_type="audio/mpeg") + == ".mp3" + ) + assert audio_suffix_from_metadata(content_type="audio/mpeg") == ".mp3" + assert audio_suffix_from_metadata(filename="clip.m4a") == ".m4a" + assert audio_suffix_from_metadata(content_type="audio/mp4") == ".mp4" + assert audio_suffix_from_metadata(content_type="audio/wav") == ".wav" diff --git a/tests/messaging/test_reliability.py b/tests/messaging/test_reliability.py new file mode 100644 index 0000000..8cbc544 --- /dev/null +++ b/tests/messaging/test_reliability.py @@ -0,0 +1,146 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from telegram.error import NetworkError, RetryAfter, TelegramError + +from free_claude_code.messaging.limiter import MessagingRateLimiter +from free_claude_code.messaging.platforms.telegram import TelegramRuntime + + +@pytest.fixture +def telegram_platform(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = TelegramRuntime( + bot_token="test_token", + allowed_user_id="12345", + limiter=MessagingRateLimiter(rate_limit=1, rate_window=1.0), + transcriber=None, + ) + return platform + + +@pytest.mark.asyncio +async def test_telegram_retry_on_network_error(telegram_platform): + mock_bot = AsyncMock() + mock_msg = MagicMock() + mock_msg.message_id = 999 + + # Fail twice, then succeed + mock_bot.send_message.side_effect = [ + NetworkError("Connection failed"), + NetworkError("Connection failed"), + mock_msg, + ] + + telegram_platform._application = MagicMock() + telegram_platform._application.bot = mock_bot + + # We need to patch asyncio.sleep to speed up the test + with patch("asyncio.sleep", AsyncMock()) as mock_sleep: + msg_id = await telegram_platform.outbound.send_message("chat_1", "hello") + + assert msg_id == "999" + assert mock_bot.send_message.call_count == 3 + assert mock_sleep.call_count == 2 + + +@pytest.mark.asyncio +async def test_telegram_retry_on_retry_after(telegram_platform): + mock_bot = AsyncMock() + mock_msg = MagicMock() + mock_msg.message_id = 1000 + + # Fail with RetryAfter, then succeed + mock_bot.send_message.side_effect = [RetryAfter(retry_after=5), mock_msg] + + telegram_platform._application = MagicMock() + telegram_platform._application.bot = mock_bot + + with patch("asyncio.sleep", AsyncMock()) as mock_sleep: + msg_id = await telegram_platform.outbound.send_message("chat_1", "hello") + + assert msg_id == "1000" + assert mock_bot.send_message.call_count == 2 + mock_sleep.assert_called_with(5) + + +@pytest.mark.asyncio +async def test_telegram_no_retry_on_bad_request(telegram_platform): + mock_bot = AsyncMock() + + # Fail with generic TelegramError (should not retry unless specific conditions met) + mock_bot.send_message.side_effect = TelegramError("Bad Request: some error") + + telegram_platform._application = MagicMock() + telegram_platform._application.bot = mock_bot + + with pytest.raises(TelegramError): + await telegram_platform.outbound.send_message("chat_1", "hello") + + assert mock_bot.send_message.call_count == 1 + + +def test_handler_build_message_hardening(): + # Formatting hardening now lives in TranscriptBuffer rendering. + from free_claude_code.messaging.rendering.telegram_markdown import ( + escape_md_v2, + escape_md_v2_code, + mdv2_bold, + mdv2_code_inline, + render_markdown_to_mdv2, + ) + from free_claude_code.messaging.transcript import RenderCtx, TranscriptBuffer + + ctx = RenderCtx( + bold=mdv2_bold, + code_inline=mdv2_code_inline, + escape_code=escape_md_v2_code, + escape_text=escape_md_v2, + render_markdown=render_markdown_to_mdv2, + ) + + # Case 1: Empty transcript + no status => empty string. + t = TranscriptBuffer() + msg = t.render(ctx, limit_chars=3900, status=None) + assert msg == "" + + # Case 2: Truncation with code block closing and status preserved. + t.apply({"type": "thinking_chunk", "text": ("thought " * 200)}) + t.apply({"type": "text_chunk", "text": ("This is a very long message. " * 300)}) + + msg = t.render(ctx, limit_chars=3900, status="Finishing...") + + assert len(msg) <= 4096 + assert "Finishing..." in msg + if "```" in msg: + assert msg.count("```") % 2 == 0 + + +def test_render_output_never_exceeds_4096(): + """Transcript render with various status lengths never exceeds Telegram 4096 limit.""" + from free_claude_code.messaging.rendering.telegram_markdown import ( + escape_md_v2, + escape_md_v2_code, + mdv2_bold, + mdv2_code_inline, + render_markdown_to_mdv2, + ) + from free_claude_code.messaging.transcript import RenderCtx, TranscriptBuffer + + ctx = RenderCtx( + bold=mdv2_bold, + code_inline=mdv2_code_inline, + escape_code=escape_md_v2_code, + escape_text=escape_md_v2, + render_markdown=render_markdown_to_mdv2, + ) + + t = TranscriptBuffer() + t.apply({"type": "thinking_chunk", "text": "x" * 500}) + t.apply({"type": "text_chunk", "text": "y" * 3500}) + + for status in [None, "Done", "✅ *Complete*", "A" * 100]: + msg = t.render(ctx, limit_chars=3900, status=status) + assert len(msg) <= 4096, f"status={status!r} produced len={len(msg)}" diff --git a/tests/messaging/test_rendering_profiles.py b/tests/messaging/test_rendering_profiles.py new file mode 100644 index 0000000..e3c4d5f --- /dev/null +++ b/tests/messaging/test_rendering_profiles.py @@ -0,0 +1,17 @@ +from free_claude_code.messaging.rendering.profiles import build_rendering_profile + + +def test_discord_rendering_profile_has_plain_parse_mode(): + profile = build_rendering_profile("discord") + + assert profile.parse_mode is None + assert profile.limit_chars == 1900 + assert profile.format_status("x", "Working", None).startswith("x") + + +def test_telegram_rendering_profile_uses_markdown_v2(): + profile = build_rendering_profile("telegram") + + assert profile.parse_mode == "MarkdownV2" + assert profile.limit_chars == 3900 + assert profile.format_status("x", "Working", None).startswith("x") diff --git a/tests/messaging/test_restart_reply_restore.py b/tests/messaging/test_restart_reply_restore.py new file mode 100644 index 0000000..d42d016 --- /dev/null +++ b/tests/messaging/test_restart_reply_restore.py @@ -0,0 +1,312 @@ +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from free_claude_code.messaging.models import IncomingMessage, MessageScope +from free_claude_code.messaging.session import SessionStore +from free_claude_code.messaging.trees import TreeIdentity +from free_claude_code.messaging.trees.node import MessageNode, MessageState +from free_claude_code.messaging.trees.runtime import MessageTree +from free_claude_code.messaging.workflow import MessagingWorkflow + +TELEGRAM_CHAT_1 = MessageScope(platform="telegram", chat_id="chat_1") + + +async def _wait_for_idle(workflow: MessagingWorkflow) -> None: + for _ in range(100): + if workflow.tree_queue.task_count() == 0: + return + await asyncio.sleep(0) + raise AssertionError("messaging claims did not finish") + + +async def _write_completed_root(store: SessionStore) -> None: + tree = MessageTree( + MessageNode( + node_id="A", + scope=TELEGRAM_CHAT_1, + prompt="A", + status_message_id="status_A", + state=MessageState.COMPLETED, + session_id="sess_A", + ) + ) + store.save_tree_snapshot(await tree.snapshot()) + store.flush_pending_save() + + +async def _write_interrupted_root(store: SessionStore) -> None: + tree = MessageTree( + MessageNode( + node_id="A", + scope=TELEGRAM_CHAT_1, + prompt="A", + status_message_id="status_A", + state=MessageState.IN_PROGRESS, + ) + ) + store.save_tree_snapshot(await tree.snapshot()) + store.flush_pending_save() + + +def _successful_session(session_id: str): + session = MagicMock() + + async def events(*_args, **_kwargs): + yield {"type": "session_info", "session_id": session_id} + yield {"type": "exit", "code": 0, "stderr": None} + + session.start_task = events + return session + + +@pytest.mark.asyncio +async def test_reply_to_old_status_message_after_restore_routes_to_parent( + tmp_path, + mock_platform, + mock_cli_manager, +) -> None: + store_path = tmp_path / "sessions.json" + await _write_completed_root(SessionStore(storage_path=str(store_path))) + + restored_store = SessionStore(storage_path=str(store_path)) + workflow = MessagingWorkflow(mock_platform, mock_cli_manager, restored_store) + workflow.restore() + mock_platform.queue_send_message = AsyncMock(return_value="status_reply") + mock_cli_manager.get_or_create_session.return_value = ( + _successful_session("sess_R1"), + "pending_R1", + True, + ) + + await workflow.handle_message( + IncomingMessage( + text="R1", + chat_id="chat_1", + user_id="user_1", + message_id="R1", + platform="telegram", + reply_to_message_id="status_A", + ) + ) + await _wait_for_idle(workflow) + + reply = await workflow.tree_queue.get_node(TELEGRAM_CHAT_1, "R1") + assert reply is not None + assert reply.parent_id == "A" + mock_cli_manager.get_or_create_session.assert_called_with(session_id="sess_A") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("wrapped", [False, True]) +async def test_legacy_session_json_restores_through_workflow_and_routes_reply( + wrapped: bool, + tmp_path, + mock_platform, + mock_cli_manager, +) -> None: + legacy_tree = { + "root_id": "A", + "nodes": { + "A": { + "node_id": "A", + "incoming": { + "text": "legacy prompt", + "chat_id": "chat_1", + "user_id": "legacy-user", + "message_id": "A", + "platform": "telegram", + }, + "status_message_id": "status_A", + "state": "completed", + "parent_id": None, + "session_id": "sess_A", + "children_ids": [], + "created_at": "2025-01-01T00:00:00+00:00", + "completed_at": "2025-01-01T00:00:01+00:00", + "error_message": None, + } + }, + } + conversation = {"trees": {"A": legacy_tree}} + payload = ( + {"conversation": conversation, "message_log": {}} + if wrapped + else { + **conversation, + "node_to_tree": {"A": "A"}, + "message_log": {}, + } + ) + store_path = tmp_path / "sessions.json" + store_path.write_text(json.dumps(payload), encoding="utf-8") + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + SessionStore(storage_path=str(store_path)), + ) + workflow.restore() + mock_platform.queue_send_message = AsyncMock(return_value="status_reply") + mock_cli_manager.get_or_create_session.return_value = ( + _successful_session("sess_R1"), + "pending_R1", + True, + ) + + await workflow.handle_message( + IncomingMessage( + text="continue legacy tree", + chat_id="chat_1", + user_id="user_1", + message_id="R1", + platform="telegram", + reply_to_message_id="status_A", + ) + ) + await _wait_for_idle(workflow) + + reply = await workflow.tree_queue.get_node(TELEGRAM_CHAT_1, "R1") + assert reply is not None and reply.parent_id == "A" + mock_cli_manager.get_or_create_session.assert_called_with(session_id="sess_A") + + +@pytest.mark.asyncio +async def test_save_tree_snapshot_restores_status_lookup_without_manual_index( + tmp_path, + mock_platform, + mock_cli_manager, +) -> None: + store_path = tmp_path / "sessions.json" + await _write_completed_root(SessionStore(storage_path=str(store_path))) + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + SessionStore(storage_path=str(store_path)), + ) + workflow.restore() + + assert await workflow.tree_queue.resolve_node_id(TELEGRAM_CHAT_1, "status_A") == "A" + + +@pytest.mark.asyncio +async def test_reply_clear_purges_removed_status_mapping_from_persisted_store( + tmp_path, + mock_platform, + mock_cli_manager, +) -> None: + store_path = tmp_path / "sessions.json" + store = SessionStore(storage_path=str(store_path)) + workflow = MessagingWorkflow(mock_platform, mock_cli_manager, store) + mock_platform.queue_send_message = AsyncMock( + side_effect=["root_status", "child_status"] + ) + mock_cli_manager.get_or_create_session.side_effect = [ + (_successful_session("sess_root"), "pending_root", True), + (_successful_session("sess_child"), "pending_child", True), + ] + + await workflow.handle_message( + IncomingMessage( + text="root", + chat_id="chat_1", + user_id="user_1", + message_id="root", + platform="telegram", + ) + ) + await _wait_for_idle(workflow) + await workflow.handle_message( + IncomingMessage( + text="child", + chat_id="chat_1", + user_id="user_1", + message_id="child", + platform="telegram", + reply_to_message_id="root", + ) + ) + await _wait_for_idle(workflow) + await workflow.handle_message( + IncomingMessage( + text="/clear", + chat_id="chat_1", + user_id="user_1", + message_id="clear_command", + platform="telegram", + reply_to_message_id="child", + ) + ) + store.flush_pending_save() + + identity = TreeIdentity(scope=TELEGRAM_CHAT_1, root_id="root") + persisted = SessionStore(storage_path=str(store_path)).load_conversation_snapshot() + tree = persisted.get_tree(identity) + assert tree is not None + assert tree.lookup_ids() == {"root", "root_status"} + + +@pytest.mark.asyncio +async def test_restore_repairs_interrupted_status_after_delivery_starts( + tmp_path, + mock_platform, + mock_cli_manager, +) -> None: + store_path = tmp_path / "sessions.json" + await _write_interrupted_root(SessionStore(storage_path=str(store_path))) + workflow = MessagingWorkflow( + mock_platform, + mock_cli_manager, + SessionStore(storage_path=str(store_path)), + platform_name="telegram", + ) + workflow.restore() + + await workflow.repair_restored_statuses() + await workflow.repair_restored_statuses() + + mock_platform.queue_edit_message.assert_awaited_once_with( + TELEGRAM_CHAT_1.chat_id, + "status_A", + workflow.format_status("❌", "Interrupted by server restart"), + parse_mode="MarkdownV2", + fire_and_forget=False, + ) + + +@pytest.mark.asyncio +async def test_workflow_close_waits_for_claim_cleanup_before_flushing( + mock_platform, + mock_cli_manager, + mock_session_store, +) -> None: + workflow = MessagingWorkflow(mock_platform, mock_cli_manager, mock_session_store) + cleanup_release = asyncio.Event() + wait_started = asyncio.Event() + events: list[str] = [] + + async def stop_all() -> int: + events.append("stop") + return 1 + + async def wait_idle() -> None: + events.append("wait") + wait_started.set() + await cleanup_release.wait() + + workflow.stop_all_tasks = AsyncMock(side_effect=stop_all) + workflow.tree_queue.wait_idle = AsyncMock(side_effect=wait_idle) + mock_session_store.flush_pending_save.side_effect = lambda: events.append("flush") + + close_task = asyncio.create_task(workflow.close()) + await wait_started.wait() + + assert events == ["stop", "wait"] + mock_session_store.flush_pending_save.assert_not_called() + + cleanup_release.set() + await close_task + + assert events == ["stop", "wait", "flush"] + mock_session_store.flush_pending_save.assert_called_once() diff --git a/tests/messaging/test_robust_formatting.py b/tests/messaging/test_robust_formatting.py new file mode 100644 index 0000000..a2470fb --- /dev/null +++ b/tests/messaging/test_robust_formatting.py @@ -0,0 +1,96 @@ +from unittest.mock import MagicMock + +import pytest + +from free_claude_code.messaging.rendering.telegram_markdown import ( + escape_md_v2, + escape_md_v2_code, + mdv2_bold, + mdv2_code_inline, + render_markdown_to_mdv2, +) +from free_claude_code.messaging.transcript import RenderCtx, TranscriptBuffer + + +@pytest.fixture +def handler(): + platform = MagicMock() + cli = MagicMock() + store = MagicMock() + return (platform, cli, store) + + +def _ctx() -> RenderCtx: + return RenderCtx( + bold=mdv2_bold, + code_inline=mdv2_code_inline, + escape_code=escape_md_v2_code, + escape_text=escape_md_v2, + render_markdown=render_markdown_to_mdv2, + ) + + +def test_truncation_closes_code_blocks(handler): + """Verify that truncation correctly closes open code blocks.""" + t = TranscriptBuffer() + t.apply( + { + "type": "thinking_chunk", + "text": "Starting some long thinking process that will definitely cause truncation later on...", + } + ) + t.apply( + { + "type": "text_chunk", + "text": "```python\ndef very_long_function():\n # " + ("A" * 4000), + } + ) + + msg = t.render(_ctx(), limit_chars=3900, status="✅ *Complete*") + + # The backtick count must be even to be a valid block. + assert msg.count("```") % 2 == 0 + assert msg.endswith("```") or "✅ *Complete*" in msg.split("```")[-1] + + +def test_truncation_preserves_status(handler): + """Verify that status is still appended after truncation.""" + status = "READY_STATUS" + t = TranscriptBuffer() + t.apply({"type": "thinking_chunk", "text": "Thinking..."}) + t.apply({"type": "text_chunk", "text": "A" * 5000}) + msg = t.render(_ctx(), limit_chars=3900, status=status) + + assert status in msg + + +def test_empty_components_with_status(handler): + """Verify message building with just a status.""" + status = "Simple Status" + t = TranscriptBuffer() + msg = t.render(_ctx(), limit_chars=3900, status=status) + assert msg == "\n\nSimple Status" + + +def test_render_markdown_unclosed_markdown(): + """Malformed markdown (e.g. unclosed *) does not crash and produces acceptable output.""" + from free_claude_code.messaging.rendering.telegram_markdown import ( + render_markdown_to_mdv2, + ) + + md = "*bold without close" + out = render_markdown_to_mdv2(md) + assert out is not None + assert "bold" in out + + +def test_escape_md_v2_unicode_emoji(): + """Unicode and emoji pass through correctly (no special char escaping needed).""" + from free_claude_code.messaging.rendering.telegram_markdown import ( + escape_md_v2, + escape_md_v2_code, + ) + + text = "Hello 世界 🎉 café" + assert escape_md_v2(text) == text + assert escape_md_v2_code(text) == text diff --git a/tests/messaging/test_session_store_edge_cases.py b/tests/messaging/test_session_store_edge_cases.py new file mode 100644 index 0000000..077060d --- /dev/null +++ b/tests/messaging/test_session_store_edge_cases.py @@ -0,0 +1,680 @@ +"""Edge case tests for the messaging session store.""" + +import json +import threading +from collections.abc import Callable +from typing import Any, ClassVar +from unittest.mock import patch + +import pytest + +import free_claude_code.messaging.session.persistence as persistence_module +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.session import SessionStore +from free_claude_code.messaging.session.persistence import DebouncedJsonPersistence +from free_claude_code.messaging.trees import TreeIdentity, TreeSnapshot + +TELEGRAM_C1 = MessageScope(platform="telegram", chat_id="c1") +TELEGRAM_C2 = MessageScope(platform="telegram", chat_id="c2") + + +def _identity(root_id: str, scope: MessageScope = TELEGRAM_C1) -> TreeIdentity: + return TreeIdentity(scope=scope, root_id=root_id) + + +@pytest.fixture +def tmp_store(tmp_path): + """Create a SessionStore using a temp file.""" + path = str(tmp_path / "sessions.json") + return SessionStore(storage_path=path) + + +def _tree_node(node_id: str, status_message_id: str) -> dict: + return { + "node_id": node_id, + "status_message_id": status_message_id, + } + + +class FakeTimer: + instances: ClassVar[list[FakeTimer]] = [] + + def __init__( + self, + interval: float, + function: Callable[..., None], + args: tuple[Any, ...] | None = None, + kwargs: dict[str, Any] | None = None, + ) -> None: + self.interval = interval + self.function = function + self.args = args or () + self.kwargs = kwargs or {} + self.daemon = False + self.canceled = False + self.started = False + self.instances.append(self) + + def cancel(self) -> None: + self.canceled = True + + def start(self) -> None: + self.started = True + + def fire(self, *, force: bool = False) -> None: + if self.canceled and not force: + return + self.function(*self.args, **self.kwargs) + + +class RecordingPersistence(DebouncedJsonPersistence): + def __init__( + self, + storage_path: str, + *, + snapshot: Callable[[], dict[str, Any]], + on_dirty: Callable[[bool], None], + ) -> None: + self.writes: list[dict[str, Any]] = [] + super().__init__(storage_path, snapshot=snapshot, on_dirty=on_dirty) + + def _write_file(self, data: dict[str, Any]) -> None: + self.writes.append(data) + + +class TestSessionStoreLoadEdgeCases: + """Tests for loading corrupted/malformed data.""" + + def test_load_corrupted_json(self, tmp_path): + """Corrupted JSON file is handled gracefully (logs error, starts empty).""" + path = str(tmp_path / "sessions.json") + with open(path, "w") as f: + f.write("{invalid json") + + store = SessionStore(storage_path=path) + assert store.load_conversation_snapshot().is_empty + + def test_load_truncated_json(self, tmp_path): + """Truncated JSON file is handled gracefully.""" + path = str(tmp_path / "sessions.json") + with open(path, "w") as f: + f.write('{"sessions": {"s1": {"session_id": "s1"') + + store = SessionStore(storage_path=path) + assert store.load_conversation_snapshot().is_empty + + def test_load_empty_file(self, tmp_path): + """Empty file is handled gracefully.""" + path = str(tmp_path / "sessions.json") + with open(path, "w") as f: + f.write("") + + store = SessionStore(storage_path=path) + assert store.load_conversation_snapshot().is_empty + + def test_load_nonexistent_file(self, tmp_path): + """Non-existent file starts with empty state.""" + path = str(tmp_path / "nonexistent.json") + store = SessionStore(storage_path=path) + assert store.load_conversation_snapshot().is_empty + + def test_load_legacy_sessions_ignored(self, tmp_path): + """Legacy sessions in file are ignored; trees and message_log load.""" + path = str(tmp_path / "sessions.json") + data = { + "sessions": { + "s1": { + "session_id": "s1", + "chat_id": 12345, + "initial_msg_id": 100, + "last_msg_id": 200, + "platform": "telegram", + "created_at": "2025-01-01T00:00:00+00:00", + "updated_at": "2025-01-01T00:00:00+00:00", + } + }, + "trees": { + "r1": { + "root_id": "r1", + "nodes": { + "r1": { + "node_id": "r1", + "incoming": { + "platform": TELEGRAM_C1.platform, + "chat_id": TELEGRAM_C1.chat_id, + }, + } + }, + } + }, + "node_to_tree": {"r1": "r1"}, + "message_log": {}, + } + with open(path, "w") as f: + json.dump(data, f) + + store = SessionStore(storage_path=path) + assert store.load_conversation_snapshot().get_tree(_identity("r1")) is not None + + +class TestSessionStoreSaveEdgeCases: + """Tests for save failure handling.""" + + def test_explicit_flush_reports_io_error_and_keeps_store_dirty(self, tmp_store): + """A durability request must not hide that the snapshot was not saved.""" + tmp_store.save_tree_snapshot( + TreeSnapshot(scope=TELEGRAM_C1, root_id="r1", nodes={"r1": {}}) + ) + with ( + patch( + "free_claude_code.messaging.session.persistence.os.replace", + side_effect=OSError("disk full"), + ), + pytest.raises(OSError, match="disk full"), + ): + tmp_store.flush_pending_save() + assert tmp_store.dirty is True + + def test_explicit_flush_snapshot_failure_is_dirty_and_retryable( + self, + tmp_path, + ) -> None: + dirty_states: list[bool] = [] + should_fail = True + + def snapshot() -> dict[str, Any]: + if should_fail: + raise RuntimeError("snapshot failed") + return {"saved": True} + + persistence = DebouncedJsonPersistence( + str(tmp_path / "sessions.json"), + snapshot=snapshot, + on_dirty=dirty_states.append, + ) + + with pytest.raises(RuntimeError, match="snapshot failed"): + persistence.flush() + + assert dirty_states[-1] is True + + should_fail = False + persistence.flush() + + assert dirty_states[-1] is False + + def test_timer_save_io_error_is_best_effort_and_keeps_store_dirty( + self, + tmp_path, + monkeypatch, + ) -> None: + """A background timer may report failure without crashing its thread.""" + FakeTimer.instances = [] + monkeypatch.setattr(persistence_module.threading, "Timer", FakeTimer) + store = SessionStore(storage_path=str(tmp_path / "sessions.json")) + store.save_tree_snapshot( + TreeSnapshot(scope=TELEGRAM_C1, root_id="r1", nodes={"r1": {}}) + ) + + with patch( + "free_claude_code.messaging.session.persistence.os.replace", + side_effect=OSError("timer disk full"), + ): + FakeTimer.instances[-1].fire() + + assert store.dirty is True + + store.flush_pending_save() + + assert store.dirty is False + assert ( + SessionStore(storage_path=store.storage_path) + .load_conversation_snapshot() + .get_tree(_identity("r1")) + is not None + ) + + def test_timer_snapshot_failure_is_best_effort_type_only_and_stays_dirty( + self, + tmp_path, + monkeypatch, + ) -> None: + FakeTimer.instances = [] + monkeypatch.setattr(persistence_module.threading, "Timer", FakeTimer) + dirty_states: list[bool] = [] + + def fail_snapshot() -> dict[str, Any]: + raise RuntimeError("SECRET_SNAPSHOT_DETAIL") + + persistence = DebouncedJsonPersistence( + str(tmp_path / "sessions.json"), + snapshot=fail_snapshot, + on_dirty=dirty_states.append, + ) + persistence.schedule_save() + + with patch.object(persistence_module.logger, "error") as error: + FakeTimer.instances[-1].fire() + + assert dirty_states[-1] is True + error.assert_called_once_with( + "Failed to save sessions: exc_type={}", + "RuntimeError", + ) + assert "SECRET_SNAPSHOT_DETAIL" not in str(error.call_args) + + def test_stale_timer_callback_cannot_clear_newer_timer(self, tmp_path, monkeypatch): + """An already-running old timer cannot consume the newest save.""" + FakeTimer.instances = [] + monkeypatch.setattr(persistence_module.threading, "Timer", FakeTimer) + + dirty_states: list[bool] = [] + snapshot_count = 0 + + def snapshot() -> dict[str, Any]: + nonlocal snapshot_count + snapshot_count += 1 + return {"snapshot": snapshot_count} + + persistence = RecordingPersistence( + str(tmp_path / "sessions.json"), + snapshot=snapshot, + on_dirty=dirty_states.append, + ) + + persistence.schedule_save() + first_timer = FakeTimer.instances[0] + persistence.schedule_save() + second_timer = FakeTimer.instances[1] + + first_timer.fire(force=True) + assert persistence.writes == [] + assert dirty_states[-1] is True + assert second_timer.canceled is False + + second_timer.fire() + assert persistence.writes == [{"snapshot": 1}] + assert dirty_states[-1] is False + + def test_running_old_write_finishes_before_newer_flush(self, tmp_path, monkeypatch): + """A claimed old snapshot cannot land after a newer flushed snapshot.""" + FakeTimer.instances = [] + monkeypatch.setattr(persistence_module.threading, "Timer", FakeTimer) + + state = {"version": "old"} + dirty_states: list[bool] = [] + old_write_started = threading.Event() + release_old_write = threading.Event() + + class BlockingPersistence(RecordingPersistence): + def _write_file(self, data: dict[str, Any]) -> None: + if data == {"version": "old"}: + old_write_started.set() + release_old_write.wait(timeout=2) + super()._write_file(data) + + persistence = BlockingPersistence( + str(tmp_path / "sessions.json"), + snapshot=lambda: dict(state), + on_dirty=dirty_states.append, + ) + persistence.schedule_save() + old_writer = threading.Thread(target=FakeTimer.instances[0].fire) + old_writer.start() + assert old_write_started.wait(timeout=2) + + state["version"] = "new" + persistence.schedule_save() + new_writer = threading.Thread(target=persistence.flush) + new_writer.start() + assert new_writer.is_alive() + + release_old_write.set() + old_writer.join(timeout=2) + new_writer.join(timeout=2) + + assert not old_writer.is_alive() + assert not new_writer.is_alive() + assert persistence.writes == [{"version": "old"}, {"version": "new"}] + assert dirty_states[-1] is False + + +class TestSessionStoreTreeSnapshots: + def test_unscoped_tree_without_legacy_ingress_is_reported_and_skipped( + self, tmp_path + ): + path = tmp_path / "sessions.json" + path.write_text( + json.dumps( + { + "conversation": { + "trees": [ + { + "root_id": "root", + "nodes": { + "root": { + "node_id": "root", + "status_message_id": "status", + "state": "completed", + } + }, + } + ] + } + } + ), + encoding="utf-8", + ) + + with patch( + "free_claude_code.messaging.trees.snapshot.logger.warning" + ) as warning: + store = SessionStore(storage_path=str(path)) + + assert store.load_conversation_snapshot().is_empty + warning.assert_called_once_with( + "Skipping messaging tree snapshot without recoverable scope: root_id={}", + "root", + ) + + def test_snapshot_ingress_and_egress_are_deeply_detached(self, tmp_path): + store = SessionStore(storage_path=str(tmp_path / "sessions.json")) + snapshot = TreeSnapshot( + scope=TELEGRAM_C1, + root_id="root", + nodes={"root": {"node_id": "root", "state": "completed"}}, + ) + store.save_tree_snapshot(snapshot) + snapshot.nodes["root"]["state"] = "mutated-after-save" + + loaded = store.load_conversation_snapshot() + loaded_tree = loaded.get_tree(_identity("root")) + assert loaded_tree is not None + assert loaded_tree.nodes["root"]["state"] == "completed" + loaded_tree.nodes["root"]["state"] = "mutated-after-load" + + reloaded = store.load_conversation_snapshot().get_tree(_identity("root")) + assert reloaded is not None + assert reloaded.nodes["root"]["state"] == "completed" + + def test_save_tree_replaces_snapshot_for_scoped_identity(self, tmp_path): + path = str(tmp_path / "sessions.json") + store = SessionStore(storage_path=path) + + store.save_tree_snapshot( + TreeSnapshot( + scope=TELEGRAM_C1, + root_id="root", + nodes={ + "root": _tree_node("root", "root_status"), + "child": _tree_node("child", "child_status"), + }, + ) + ) + + saved = store.load_conversation_snapshot().get_tree(_identity("root")) + assert saved is not None + assert saved.lookup_ids() == { + "root", + "root_status", + "child", + "child_status", + } + + store.save_tree_snapshot( + TreeSnapshot( + scope=TELEGRAM_C1, + root_id="root", + nodes={ + "root": _tree_node("root", "root_status"), + }, + ) + ) + + replaced = store.load_conversation_snapshot().get_tree(_identity("root")) + assert replaced is not None + assert replaced.lookup_ids() == {"root", "root_status"} + + def test_remove_tree_removes_only_scoped_identity(self, tmp_path): + path = str(tmp_path / "sessions.json") + store = SessionStore(storage_path=path) + store.save_tree_snapshot( + TreeSnapshot( + scope=TELEGRAM_C1, + root_id="root", + nodes={ + "root": _tree_node("root", "root_status"), + "child": _tree_node("child", "child_status"), + }, + ) + ) + + store.remove_tree_snapshot(_identity("root")) + + assert store.load_conversation_snapshot().get_tree(_identity("root")) is None + + +class TestSessionStoreAtomicWrites: + """Atomic persistence: failed replace must not truncate the prior file.""" + + def test_failed_replace_keeps_prior_bytes_and_marks_dirty(self, tmp_path): + path = str(tmp_path / "sessions.json") + store = SessionStore(storage_path=path) + store.save_tree_snapshot( + TreeSnapshot(scope=TELEGRAM_C1, root_id="r1", nodes={"r1": {}}) + ) + store.flush_pending_save() + with open(path, encoding="utf-8") as f: + disk_after_first = f.read() + + store.save_tree_snapshot( + TreeSnapshot(scope=TELEGRAM_C1, root_id="r2", nodes={"r2": {}}) + ) + + with ( + patch( + "free_claude_code.messaging.session.persistence.os.replace", + side_effect=OSError("replace failed"), + ), + pytest.raises(OSError, match="replace failed"), + ): + store.flush_pending_save() + + with open(path, encoding="utf-8") as f: + disk_after_failed = f.read() + assert disk_after_failed == disk_after_first + assert store.dirty is True + assert store.load_conversation_snapshot().get_tree(_identity("r2")) is not None + + def test_failed_authoritative_clear_is_visible_and_retryable(self, tmp_path): + path = str(tmp_path / "sessions.json") + store = SessionStore(storage_path=path) + store.save_tree_snapshot( + TreeSnapshot(scope=TELEGRAM_C1, root_id="r1", nodes={"r1": {}}) + ) + store.flush_pending_save() + + with ( + patch( + "free_claude_code.messaging.session.persistence.os.replace", + side_effect=OSError("clear replace failed"), + ), + pytest.raises(OSError, match="clear replace failed"), + ): + store.clear_scope(TELEGRAM_C1) + + assert store.load_conversation_snapshot().is_empty + assert store.dirty is True + assert not SessionStore(storage_path=path).load_conversation_snapshot().is_empty + + store.flush_pending_save() + + assert store.dirty is False + assert SessionStore(storage_path=path).load_conversation_snapshot().is_empty + + def test_authoritative_snapshot_failure_marks_store_dirty_before_retry( + self, + tmp_path, + ) -> None: + store = SessionStore(storage_path=str(tmp_path / "sessions.json")) + + with ( + patch.object( + store, + "_snapshot_for_persistence", + side_effect=RuntimeError("authoritative snapshot failed"), + ), + pytest.raises(RuntimeError, match="authoritative snapshot failed"), + ): + store.clear_scope(TELEGRAM_C1) + + assert store.dirty is True + + store.clear_scope(TELEGRAM_C1) + + assert store.dirty is False + + +class TestSessionStoreClearScope: + def test_clear_scope_wipes_matching_state_and_persists(self, tmp_path): + path = str(tmp_path / "sessions.json") + store = SessionStore(storage_path=path) + + store.save_tree_snapshot( + TreeSnapshot( + scope=TELEGRAM_C1, + root_id="root1", + nodes={ + "root1": { + "node_id": "root1", + "incoming": { + "text": "hello", + "chat_id": "c1", + "user_id": "u1", + "message_id": "m1", + "platform": "telegram", + "reply_to_message_id": None, + }, + "status_message_id": "status1", + "state": "pending", + "parent_id": None, + "session_id": None, + "children_ids": [], + "created_at": "2025-01-01T00:00:00+00:00", + "completed_at": None, + "error_message": None, + } + }, + ) + ) + + store.clear_scope(TELEGRAM_C1) + + assert store.load_conversation_snapshot().is_empty + + with open(path, encoding="utf-8") as f: + data = json.load(f) + assert data["conversation"]["trees"] == [] + assert data["managed_messages"] == {} + + store2 = SessionStore(storage_path=path) + assert store2.load_conversation_snapshot().is_empty + + def test_clear_scope_preserves_other_chat_trees_and_messages(self, tmp_path): + path = str(tmp_path / "sessions.json") + store = SessionStore(storage_path=path) + other_tree = TreeSnapshot( + scope=TELEGRAM_C2, + root_id="root2", + nodes={ + "root2": { + "node_id": "root2", + "status_message_id": "status2", + "state": "completed", + "parent_id": None, + "parent_reference_id": None, + "session_id": "session2", + } + }, + ) + store.save_tree_snapshot(other_tree) + store.record_message_id("telegram", "c1", "message1", "in", "prompt") + store.record_message_id("telegram", "c2", "message2", "in", "prompt") + + store.clear_scope(TELEGRAM_C1) + + assert ( + store.load_conversation_snapshot().get_tree(other_tree.identity) is not None + ) + assert store.get_tracked_message_ids_for_chat("telegram", "c1") == [] + assert store.get_tracked_message_ids_for_chat("telegram", "c2") == ["message2"] + restored = SessionStore(storage_path=path) + assert ( + restored.load_conversation_snapshot().get_tree(other_tree.identity) + is not None + ) + assert restored.get_tracked_message_ids_for_chat("telegram", "c2") == [ + "message2" + ] + + def test_managed_message_log_persists_and_dedups(self, tmp_path): + path = str(tmp_path / "sessions.json") + store = SessionStore(storage_path=path) + + store.record_message_id("telegram", "c1", "2", "out", "command") + store.record_message_id("telegram", "c1", "2", "out", "command") + store.record_message_id("telegram", "c1", "3", "in", "command") + + ids = store.get_tracked_message_ids_for_chat("telegram", "c1") + assert ids == ["2", "3"] + + store.flush_pending_save() + store2 = SessionStore(storage_path=path) + assert store2.get_tracked_message_ids_for_chat("telegram", "c1") == [ + "2", + "3", + ] + + def test_load_preserves_legacy_managed_messages(self, tmp_path): + path = tmp_path / "sessions.json" + path.write_text( + json.dumps( + { + "conversation": {"trees": []}, + "message_log": { + "telegram:c1": [ + { + "message_id": "prompt", + "direction": "in", + "kind": "content", + }, + { + "message_id": "old-command", + "direction": "in", + "kind": "command", + }, + { + "message_id": "status", + "direction": "out", + "kind": "status", + }, + { + "message_id": "clear-command", + "direction": "in", + "kind": "clear_command", + }, + ] + }, + } + ), + encoding="utf-8", + ) + + store = SessionStore(storage_path=str(path)) + + assert store.get_tracked_message_ids_for_chat("telegram", "c1") == [ + "prompt", + "old-command", + "status", + "clear-command", + ] diff --git a/tests/messaging/test_stream_transcript_contract.py b/tests/messaging/test_stream_transcript_contract.py new file mode 100644 index 0000000..ab5b8ac --- /dev/null +++ b/tests/messaging/test_stream_transcript_contract.py @@ -0,0 +1,60 @@ +"""Messaging-specific assertions built on neutral Anthropic stream contracts.""" + +from free_claude_code.core.anthropic import AnthropicStreamLedger +from free_claude_code.core.anthropic.stream_contracts import ( + assert_anthropic_stream_contract, + has_tool_use, + parse_sse_text, +) +from free_claude_code.messaging.event_parser import parse_cli_event +from free_claude_code.messaging.transcript import RenderCtx, TranscriptBuffer + + +def test_thinking_tool_text_and_transcript_order_contract() -> None: + builder = AnthropicStreamLedger("msg_contract", "contract-model") + chunks = [builder.message_start()] + chunks.extend(builder.ensure_thinking_block()) + chunks.append(builder.emit_thinking_delta("inspect first")) + chunks.extend(builder.close_content_blocks()) + tool_block_index = builder.blocks.allocate_index() + chunks.append( + builder.content_block_start( + tool_block_index, "tool_use", id="toolu_1", name="Read" + ) + ) + chunks.append( + builder.content_block_delta( + tool_block_index, "input_json_delta", '{"file":"README.md"}' + ) + ) + chunks.append(builder.content_block_stop(tool_block_index)) + chunks.extend(builder.ensure_text_block()) + chunks.append(builder.emit_text_delta("done")) + chunks.extend(builder.close_all_blocks()) + chunks.append(builder.message_delta("end_turn", 20)) + chunks.append(builder.message_stop()) + + events = parse_sse_text("".join(chunks)) + assert_anthropic_stream_contract(events) + assert has_tool_use(events) + + transcript = TranscriptBuffer() + for event in events: + for parsed in parse_cli_event(event.data): + transcript.apply(parsed) + rendered = transcript.render(_render_ctx(), limit_chars=3900, status=None) + assert ( + rendered.find("inspect first") + < rendered.find("Tool call:") + < rendered.find("done") + ) + + +def _render_ctx() -> RenderCtx: + return RenderCtx( + bold=lambda s: f"*{s}*", + code_inline=lambda s: f"`{s}`", + escape_code=lambda s: s, + escape_text=lambda s: s, + render_markdown=lambda s: s, + ) diff --git a/tests/messaging/test_telegram.py b/tests/messaging/test_telegram.py new file mode 100644 index 0000000..c4baac6 --- /dev/null +++ b/tests/messaging/test_telegram.py @@ -0,0 +1,312 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from telegram.error import TelegramError + +from free_claude_code.messaging.platforms.telegram import TelegramRuntime + + +def _limiter_mock() -> MagicMock: + limiter = MagicMock() + limiter.start = MagicMock() + limiter.shutdown = AsyncMock() + return limiter + + +def _telegram_runtime( + *args, limiter=None, transcriber=None, **kwargs +) -> TelegramRuntime: + return TelegramRuntime( + *args, + limiter=limiter or _limiter_mock(), + transcriber=transcriber, + **kwargs, + ) + + +@pytest.fixture +def telegram_platform(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="test_token", allowed_user_id="12345") + return platform + + +def test_telegram_platform_init_no_token(): + with patch.dict("os.environ", {}, clear=True): + platform = _telegram_runtime(bot_token=None) + assert platform.bot_token is None + + +@pytest.mark.asyncio +async def test_telegram_platform_start_success(telegram_platform): + telegram_platform.outbound.send_message = AsyncMock() + with patch("telegram.ext.Application.builder") as mock_builder: + mock_app = MagicMock() + mock_app.initialize = AsyncMock() + mock_app.start = AsyncMock() + mock_app.updater.start_polling = AsyncMock() + + mock_builder.return_value.token.return_value.request.return_value.build.return_value = mock_app + + await telegram_platform.start() + + assert telegram_platform._connected is True + mock_app.initialize.assert_called_once() + mock_app.start.assert_called_once() + telegram_platform._limiter.start.assert_called_once_with() + telegram_platform.outbound.send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_telegram_platform_start_with_proxy(): + limiter = _limiter_mock() + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime( + bot_token="test_token", + allowed_user_id="12345", + telegram_proxy_url="socks5://127.0.0.1:1080", + limiter=limiter, + ) + + with ( + patch("telegram.ext.Application.builder") as mock_builder, + patch( + "free_claude_code.messaging.platforms.telegram.HTTPXRequest" + ) as request_cls, + ): + mock_app = MagicMock() + mock_app.initialize = AsyncMock() + mock_app.start = AsyncMock() + mock_app.updater.start_polling = AsyncMock() + + builder = mock_builder.return_value + builder.token.return_value = builder + builder.request.return_value = builder + builder.get_updates_request.return_value = builder + builder.build.return_value = mock_app + request = MagicMock() + update_request = MagicMock() + request_cls.side_effect = [request, update_request] + + await platform.start() + + assert request_cls.call_count == 2 + request_cls.assert_any_call( + connection_pool_size=8, + connect_timeout=30.0, + read_timeout=30.0, + proxy="socks5://127.0.0.1:1080", + ) + builder.request.assert_called_once_with(request) + builder.get_updates_request.assert_called_once_with(update_request) + assert platform._connected is True + limiter.start.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_telegram_platform_send_message_success(telegram_platform): + mock_bot = AsyncMock() + mock_msg = MagicMock() + mock_msg.message_id = 999 + mock_bot.send_message.return_value = mock_msg + + telegram_platform._application = MagicMock() + telegram_platform._application.bot = mock_bot + + msg_id = await telegram_platform.outbound.send_message("chat_1", "hello") + + assert msg_id == "999" + mock_bot.send_message.assert_called_once_with( + chat_id="chat_1", + text="hello", + reply_to_message_id=None, + parse_mode="MarkdownV2", + ) + + +@pytest.mark.asyncio +async def test_telegram_platform_edit_message_success(telegram_platform): + mock_bot = AsyncMock() + telegram_platform._application = MagicMock() + telegram_platform._application.bot = mock_bot + + await telegram_platform.outbound.edit_message("chat_1", "999", "new text") + + mock_bot.edit_message_text.assert_called_once_with( + chat_id="chat_1", message_id=999, text="new text", parse_mode="MarkdownV2" + ) + + +@pytest.mark.asyncio +async def test_telegram_platform_delete_messages_uses_batch_api(telegram_platform): + mock_bot = AsyncMock() + telegram_platform._application = MagicMock() + telegram_platform._application.bot = mock_bot + + await telegram_platform.outbound.delete_messages("chat_1", ["1", "2", "bad"]) + + mock_bot.delete_messages.assert_awaited_once_with( + chat_id="chat_1", + message_ids=[1, 2], + ) + mock_bot.delete_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_telegram_platform_delete_messages_chunks_batch_api(telegram_platform): + mock_bot = AsyncMock() + telegram_platform._application = MagicMock() + telegram_platform._application.bot = mock_bot + + await telegram_platform.outbound.delete_messages( + "chat_1", + [str(i) for i in range(105)], + ) + + assert mock_bot.delete_messages.await_count == 2 + assert mock_bot.delete_messages.await_args_list[0].kwargs["message_ids"] == list( + range(100) + ) + assert mock_bot.delete_messages.await_args_list[1].kwargs["message_ids"] == list( + range(100, 105) + ) + + +@pytest.mark.asyncio +async def test_telegram_platform_delete_messages_falls_back_without_batch( + telegram_platform, +): + class BotWithoutBatch: + def __init__(self) -> None: + self.delete_message = AsyncMock() + + bot = BotWithoutBatch() + telegram_platform._application = MagicMock() + telegram_platform._application.bot = bot + + await telegram_platform.outbound.delete_messages("chat_1", ["1", "2"]) + + assert bot.delete_message.await_args_list[0].kwargs == { + "chat_id": "chat_1", + "message_id": 1, + } + assert bot.delete_message.await_args_list[1].kwargs == { + "chat_id": "chat_1", + "message_id": 2, + } + + +@pytest.mark.asyncio +async def test_telegram_platform_delete_messages_falls_back_after_batch_failure( + telegram_platform, +): + mock_bot = AsyncMock() + mock_bot.delete_messages.side_effect = RuntimeError("bulk failed") + telegram_platform._application = MagicMock() + telegram_platform._application.bot = mock_bot + + await telegram_platform.outbound.delete_messages("chat_1", ["1", "2"]) + + mock_bot.delete_messages.assert_awaited_once_with( + chat_id="chat_1", + message_ids=[1, 2], + ) + assert mock_bot.delete_message.await_args_list[0].kwargs == { + "chat_id": "chat_1", + "message_id": 1, + } + assert mock_bot.delete_message.await_args_list[1].kwargs == { + "chat_id": "chat_1", + "message_id": 2, + } + + +@pytest.mark.asyncio +async def test_telegram_platform_delete_messages_falls_back_after_swallowed_error( + telegram_platform, +): + mock_bot = AsyncMock() + mock_bot.delete_messages.side_effect = TelegramError("message can't be deleted") + telegram_platform._application = MagicMock() + telegram_platform._application.bot = mock_bot + + await telegram_platform.outbound.delete_messages("chat_1", ["1", "2"]) + + mock_bot.delete_messages.assert_awaited_once_with( + chat_id="chat_1", + message_ids=[1, 2], + ) + assert mock_bot.delete_message.await_args_list[0].kwargs == { + "chat_id": "chat_1", + "message_id": 1, + } + assert mock_bot.delete_message.await_args_list[1].kwargs == { + "chat_id": "chat_1", + "message_id": 2, + } + + +@pytest.mark.asyncio +async def test_telegram_platform_single_delete_still_swallows_known_error( + telegram_platform, +): + mock_bot = AsyncMock() + mock_bot.delete_message.side_effect = TelegramError("message can't be deleted") + telegram_platform._application = MagicMock() + telegram_platform._application.bot = mock_bot + + await telegram_platform.outbound.delete_message("chat_1", "1") + + mock_bot.delete_message.assert_awaited_once_with( + chat_id="chat_1", + message_id=1, + ) + + +@pytest.mark.asyncio +async def test_telegram_platform_queue_send_message(telegram_platform): + mock_limiter = telegram_platform._limiter + mock_limiter.enqueue = AsyncMock() + + await telegram_platform.outbound.queue_send_message( + "chat_1", "hello", fire_and_forget=False + ) + + mock_limiter.enqueue.assert_called_once() + + +@pytest.mark.asyncio +async def test_on_telegram_message_authorized(telegram_platform): + handler = AsyncMock() + telegram_platform.on_message(handler) + + mock_update = MagicMock() + mock_update.message.text = "hello" + mock_update.message.message_id = 1 + mock_update.effective_user.id = 12345 + mock_update.effective_chat.id = 6789 + mock_update.message.reply_to_message = None + + await telegram_platform._on_telegram_message(mock_update, MagicMock()) + + handler.assert_called_once() + incoming = handler.call_args[0][0] + assert incoming.text == "hello" + + +@pytest.mark.asyncio +async def test_on_telegram_message_unauthorized(telegram_platform): + handler = AsyncMock() + telegram_platform.on_message(handler) + + mock_update = MagicMock() + mock_update.message.text = "hello" + mock_update.effective_user.id = 99999 # Unauthorized + + await telegram_platform._on_telegram_message(mock_update, MagicMock()) + + handler.assert_not_called() diff --git a/tests/messaging/test_telegram_edge_cases.py b/tests/messaging/test_telegram_edge_cases.py new file mode 100644 index 0000000..2da9fe8 --- /dev/null +++ b/tests/messaging/test_telegram_edge_cases.py @@ -0,0 +1,539 @@ +import asyncio +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from telegram.error import NetworkError, RetryAfter, TelegramError + + +def _limiter_mock() -> MagicMock: + limiter = MagicMock() + limiter.start = MagicMock() + limiter.shutdown = AsyncMock() + return limiter + + +def _telegram_runtime(*args, limiter=None, transcriber=None, **kwargs): + from free_claude_code.messaging.platforms.telegram import TelegramRuntime + + return TelegramRuntime( + *args, + limiter=limiter or _limiter_mock(), + transcriber=transcriber, + **kwargs, + ) + + +def test_telegram_platform_init_raises_when_dependency_missing(): + with ( + patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", False + ), + pytest.raises(ImportError), + ): + _telegram_runtime(bot_token="x") + + +@pytest.mark.asyncio +async def test_telegram_platform_start_requires_token(): + with ( + patch.dict("os.environ", {}, clear=True), + patch("free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True), + ): + platform = _telegram_runtime(bot_token=None) + with pytest.raises(ValueError): + await platform.start() + + +@pytest.mark.asyncio +async def test_telegram_platform_quiesce_and_close_without_application(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + platform._application = None + platform._connected = True + await platform.quiesce() + await platform.close() + assert platform.is_connected is False + platform._limiter.shutdown.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_telegram_close_cleans_up_partially_initialized_application(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + platform._application = MagicMock() + platform._application.running = False + platform._application.updater.running = False + platform._application.updater.stop = AsyncMock() + platform._application.stop = AsyncMock() + platform._application.shutdown = AsyncMock() + platform.outbound.close = AsyncMock() + + await platform.quiesce() + await platform.close() + + platform._application.updater.stop.assert_not_awaited() + platform._application.stop.assert_not_awaited() + platform.outbound.close.assert_awaited_once_with() + platform._limiter.shutdown.assert_awaited_once_with() + platform._application.shutdown.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_telegram_two_phase_lifecycle_drains_before_delivery_close(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + order: list[str] = [] + platform._application = MagicMock() + platform._application.running = True + platform._application.updater.running = True + platform._application.updater.stop = AsyncMock( + side_effect=lambda: order.append("updater.stop") + ) + platform._application.stop = AsyncMock( + side_effect=lambda: order.append("application.stop") + ) + platform.outbound.close = AsyncMock( + side_effect=lambda: order.append("outbound.close") + ) + platform._limiter.shutdown = AsyncMock( + side_effect=lambda: order.append("limiter.shutdown") + ) + platform._application.shutdown = AsyncMock( + side_effect=lambda: order.append("application.shutdown") + ) + + await platform.quiesce() + assert order == ["updater.stop", "application.stop"] + + await platform.close() + + assert order == [ + "updater.stop", + "application.stop", + "outbound.close", + "limiter.shutdown", + "application.shutdown", + ] + assert platform.is_connected is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failing_step", + ["updater.stop", "application.stop"], +) +async def test_telegram_quiesce_attempts_all_steps_after_failure(failing_step): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + order: list[str] = [] + + async def record(step: str) -> None: + order.append(step) + if step == failing_step: + raise RuntimeError(step) + + def action(step: str): + async def run() -> None: + await record(step) + + return run + + platform._application = MagicMock() + platform._application.running = True + platform._application.updater.running = True + platform._application.updater.stop = AsyncMock( + side_effect=action("updater.stop") + ) + platform._application.stop = AsyncMock(side_effect=action("application.stop")) + platform.outbound.close = AsyncMock(side_effect=action("outbound.close")) + platform._limiter.shutdown = AsyncMock(side_effect=action("limiter.shutdown")) + platform._application.shutdown = AsyncMock( + side_effect=action("application.shutdown") + ) + + with pytest.raises(RuntimeError, match=failing_step): + await platform.quiesce() + + assert order == ["updater.stop", "application.stop"] + assert platform.is_connected is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "failing_step", + ["outbound.close", "limiter.shutdown", "application.shutdown"], +) +async def test_telegram_close_attempts_all_steps_after_failure(failing_step): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + order: list[str] = [] + + async def record(step: str) -> None: + order.append(step) + if step == failing_step: + raise RuntimeError(step) + + def action(step: str): + async def run() -> None: + await record(step) + + return run + + platform._application = MagicMock() + platform._application.shutdown = AsyncMock( + side_effect=action("application.shutdown") + ) + platform.outbound.close = AsyncMock(side_effect=action("outbound.close")) + platform._limiter.shutdown = AsyncMock(side_effect=action("limiter.shutdown")) + + with pytest.raises(RuntimeError, match=failing_step): + await platform.close() + + assert order == [ + "outbound.close", + "limiter.shutdown", + "application.shutdown", + ] + + +@pytest.mark.asyncio +async def test_with_retry_returns_none_when_message_not_modified_network_error(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + + async def _f(): + raise NetworkError("Message is not modified") + + assert await platform.outbound._with_retry(_f) is None + + +@pytest.mark.asyncio +async def test_with_retry_retries_network_error_then_succeeds(monkeypatch): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + + monkeypatch.setattr(asyncio, "sleep", AsyncMock()) + + calls = {"n": 0} + + async def _f(): + calls["n"] += 1 + if calls["n"] == 1: + raise NetworkError("temporary") + return "ok" + + assert await platform.outbound._with_retry(_f) == "ok" + assert calls["n"] == 2 + + +@pytest.mark.asyncio +async def test_with_retry_honors_retry_after_timedelta(monkeypatch): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + + monkeypatch.setattr(asyncio, "sleep", AsyncMock()) + + calls = {"n": 0} + + async def _f(): + calls["n"] += 1 + if calls["n"] == 1: + raise RetryAfter(retry_after=timedelta(seconds=0.01)) + return "ok" + + assert await platform.outbound._with_retry(_f) == "ok" + assert calls["n"] == 2 + + +@pytest.mark.asyncio +async def test_with_retry_drops_parse_mode_on_markdown_entity_error(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + + calls = [] + + async def _f(parse_mode=None): + calls.append(parse_mode) + if len(calls) == 1: + raise TelegramError("Can't parse entities: bad markdown") + return "ok" + + assert await platform.outbound._with_retry(_f, parse_mode="MarkdownV2") == "ok" + assert calls == ["MarkdownV2", None] + + +@pytest.mark.asyncio +async def test_with_retry_can_raise_known_message_errors_for_bulk_fallback(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + + async def _f(): + raise TelegramError("message can't be deleted") + + with pytest.raises(TelegramError): + await platform.outbound._with_retry( + _f, + suppress_known_message_errors=False, + ) + + +@pytest.mark.asyncio +async def test_queue_send_message_uses_required_limiter(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + platform._application = MagicMock() + mock_msg = MagicMock() + mock_msg.message_id = 1 + platform._application.bot = AsyncMock() + platform._application.bot.send_message = AsyncMock(return_value=mock_msg) + + async def enqueue(operation, dedup_key=None): + return await operation() + + platform._limiter.enqueue = AsyncMock(side_effect=enqueue) + assert ( + await platform.outbound.queue_send_message("c", "t", fire_and_forget=False) + == "1" + ) + platform._limiter.enqueue.assert_awaited_once() + platform._application.bot.send_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_queue_edit_message_uses_required_limiter(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + platform._application = MagicMock() + platform._application.bot = AsyncMock() + platform._application.bot.edit_message_text = AsyncMock() + + async def enqueue(operation, dedup_key=None): + return await operation() + + platform._limiter.enqueue = AsyncMock(side_effect=enqueue) + await platform.outbound.queue_edit_message("c", "1", "t", fire_and_forget=False) + platform._limiter.enqueue.assert_awaited_once() + platform._application.bot.edit_message_text.assert_awaited_once() + + +def test_fire_and_forget_non_coroutine_uses_ensure_future(monkeypatch): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + + ef = MagicMock() + monkeypatch.setattr(asyncio, "ensure_future", ef) + + platform.outbound.fire_and_forget(MagicMock()) + ef.assert_called_once() + + +@pytest.mark.asyncio +async def test_on_start_command_replies_and_forwards(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + with patch.object( + platform, "_on_telegram_message", new_callable=AsyncMock + ) as mock_msg: + update = MagicMock() + update.message.reply_text = AsyncMock() + + await platform._on_start_command(update, MagicMock()) + update.message.reply_text.assert_awaited_once() + mock_msg.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_on_telegram_message_handler_error_sends_error_message(): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t", allowed_user_id="123") + with patch.object( + platform.outbound, "send_message", new_callable=AsyncMock + ) as mock_send: + + async def _boom(_incoming): + raise RuntimeError("bad") + + platform.on_message(_boom) + + update = MagicMock() + update.message.text = "hello" + update.message.message_id = 7 + update.message.reply_to_message = None + update.effective_user.id = 123 + update.effective_chat.id = 456 + + await platform._on_telegram_message(update, MagicMock()) + mock_send.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_telegram_start_retries_on_network_error(monkeypatch): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="token", allowed_user_id=None) + + monkeypatch.setattr(asyncio, "sleep", AsyncMock()) + + with patch("telegram.ext.Application.builder") as mock_builder: + mock_app = MagicMock() + mock_app.initialize = AsyncMock(side_effect=[NetworkError("no"), None]) + mock_app.start = AsyncMock() + mock_app.updater = None + + mock_builder.return_value.token.return_value.request.return_value.build.return_value = mock_app + + await platform.start() + assert platform.is_connected is True + assert mock_app.initialize.await_count == 2 + mock_app.start.assert_awaited_once_with() + platform._limiter.start.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_telegram_polling_retry_does_not_restart_running_application( + monkeypatch, +): + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="token", allowed_user_id=None) + + monkeypatch.setattr(asyncio, "sleep", AsyncMock()) + + with patch("telegram.ext.Application.builder") as mock_builder: + mock_app = MagicMock() + mock_app.initialize = AsyncMock() + mock_app.start = AsyncMock() + mock_app.updater.start_polling = AsyncMock( + side_effect=[NetworkError("temporary polling failure"), None] + ) + mock_builder.return_value.token.return_value.request.return_value.build.return_value = mock_app + + await platform.start() + + assert platform.is_connected is True + mock_app.initialize.assert_awaited_once_with() + mock_app.start.assert_awaited_once_with() + assert mock_app.updater.start_polling.await_count == 2 + platform._limiter.start.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_edit_message_with_text_exceeding_4096_raises(): + """edit_message with text > 4096 raises TelegramError (BadRequest).""" + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + platform._application = MagicMock() + platform._application.bot = AsyncMock() + platform._application.bot.edit_message_text = AsyncMock( + side_effect=TelegramError("Bad Request: message is too long") + ) + + with pytest.raises(TelegramError): + await platform.outbound.edit_message("c", "1", "x" * 5000) + + +@pytest.mark.asyncio +async def test_edit_message_empty_string(): + """edit_message with empty string - Telegram accepts (no-op edit).""" + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + platform._application = MagicMock() + platform._application.bot = AsyncMock() + platform._application.bot.edit_message_text = AsyncMock() + + await platform.outbound.edit_message("c", "1", "") + platform._application.bot.edit_message_text.assert_awaited_once_with( + chat_id="c", message_id=1, text="", parse_mode="MarkdownV2" + ) + + +@pytest.mark.asyncio +async def test_send_message_empty_string(): + """send_message with empty string - Telegram may reject; we pass through.""" + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + platform._application = MagicMock() + mock_msg = MagicMock() + mock_msg.message_id = 1 + platform._application.bot = AsyncMock() + platform._application.bot.send_message = AsyncMock(return_value=mock_msg) + + msg_id = await platform.outbound.send_message("c", "") + assert msg_id == "1" + platform._application.bot.send_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_on_telegram_message_non_text_update_ignored(): + """Update with message.photo but no text returns early without calling handler.""" + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t", allowed_user_id="123") + handler = AsyncMock() + platform.on_message(handler) + + update = MagicMock() + update.message.text = None + update.message.photo = [MagicMock()] + update.message.message_id = 7 + update.message.reply_to_message = None + update.effective_user.id = 123 + update.effective_chat.id = 456 + + await platform._on_telegram_message(update, MagicMock()) + handler.assert_not_called() + + +@pytest.mark.asyncio +async def test_with_retry_message_not_found_returns_none(): + """'message to edit not found' returns None without retry.""" + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = _telegram_runtime(bot_token="t") + + async def _f(): + raise TelegramError("message to edit not found") + + result = await platform.outbound._with_retry(_f) + assert result is None diff --git a/tests/messaging/test_transcript.py b/tests/messaging/test_transcript.py new file mode 100644 index 0000000..632ef29 --- /dev/null +++ b/tests/messaging/test_transcript.py @@ -0,0 +1,361 @@ +from free_claude_code.messaging.rendering.telegram_markdown import ( + escape_md_v2, + escape_md_v2_code, + mdv2_bold, + mdv2_code_inline, + render_markdown_to_mdv2, +) +from free_claude_code.messaging.transcript import RenderCtx, TranscriptBuffer +from free_claude_code.messaging.transcript.renderer import render_segments +from free_claude_code.messaging.transcript.segments import Segment, SubagentSegment +from free_claude_code.messaging.transcript.subagents import SubagentState + + +def _ctx() -> RenderCtx: + return RenderCtx( + bold=mdv2_bold, + code_inline=mdv2_code_inline, + escape_code=escape_md_v2_code, + escape_text=escape_md_v2, + render_markdown=render_markdown_to_mdv2, + thinking_tail_max=1000, + tool_input_tail_max=1200, + tool_output_tail_max=1600, + text_tail_max=2000, + ) + + +def test_transcript_order_thinking_tool_text(): + t = TranscriptBuffer() + t.apply({"type": "thinking_chunk", "text": "think1"}) + t.apply({"type": "tool_use", "id": "tool_1", "name": "ls", "input": {"path": "."}}) + t.apply({"type": "text_chunk", "text": "done"}) + + out = t.render(_ctx(), limit_chars=3900, status=None) + assert out.find("think1") < out.find("Tool call:") < out.find("done") + + +def test_transcript_can_hide_tool_results(): + t = TranscriptBuffer(show_tool_results=False) + t.apply({"type": "tool_use", "id": "tool_1", "name": "ls", "input": {"path": "."}}) + t.apply({"type": "tool_result", "tool_use_id": "tool_1", "content": "secret"}) + + out = t.render(_ctx(), limit_chars=3900, status=None) + assert "Tool call:" in out + assert "Tool result:" not in out + assert "secret" not in out + + +def test_transcript_subagent_suppresses_thinking_and_text_inside(): + t = TranscriptBuffer() + + # Enter subagent context (Task tool call). + t.apply( + { + "type": "tool_use", + "id": "task_1", + "name": "Task", + "input": {"description": "Fix bug"}, + } + ) + + # These should be suppressed while inside subagent context. + t.apply({"type": "thinking_delta", "index": -1, "text": "secret"}) + t.apply({"type": "text_chunk", "text": "visible?"}) + + # Tool activity should still show. + t.apply({"type": "tool_use", "id": "tool_2", "name": "ls", "input": {"path": "."}}) + t.apply({"type": "tool_result", "tool_use_id": "tool_2", "content": "x"}) + + # Close subagent context (Task tool result). + t.apply({"type": "tool_result", "tool_use_id": "task_1", "content": "done"}) + + # Now text should show again. + t.apply({"type": "text_chunk", "text": "after"}) + + out = t.render(_ctx(), limit_chars=3900, status=None) + assert "Subagent:" in out + assert "secret" not in out + assert "visible?" not in out + # Only the current tool call should be shown (not the full history). + assert out.count("Tool call:") == 1 + assert "\n 🛠" in out or out.startswith(" 🛠") or " 🛠" in out + assert "Tools used:" in out + assert "Tool calls:" in out + assert "after" in out + + +def test_transcript_subagent_closes_on_whitespace_tool_ids(): + t = TranscriptBuffer() + + # Provider emitted a Task tool_use id with leading whitespace. + t.apply( + { + "type": "tool_use", + "id": " functions.Task:0", + "name": "Task", + "input": {"description": "Outer"}, + } + ) + + # Task completes, but tool_result references a trimmed id (or vice versa). + t.apply( + {"type": "tool_result", "tool_use_id": "functions.Task:0", "content": "done"} + ) + + # Next Task should be top-level, not nested under the previous subagent. + t.apply( + { + "type": "tool_use", + "id": "functions.Task:1", + "name": "Task", + "input": {"description": "Next"}, + } + ) + + out = t.render(_ctx(), limit_chars=3900, status=None) + assert out.count("Subagent:") == 2 + # If nesting is incorrect, the second subagent line will be indented under the first. + assert "\n 🤖 *Subagent:* `Next`" not in out + + +def test_transcript_subagent_closes_on_task_result_id_suffix_match(): + t = TranscriptBuffer() + t.apply( + { + "type": "tool_use", + "id": "task_1", + "name": "Task", + "input": {"description": "Outer"}, + } + ) + t.apply({"type": "tool_result", "tool_use_id": "task_1_result", "content": "done"}) + t.apply( + { + "type": "tool_use", + "id": "task_2", + "name": "Task", + "input": {"description": "Next"}, + } + ) + + out = t.render(_ctx(), limit_chars=3900, status=None) + assert out.count("Subagent:") == 2 + assert "\n 🤖 *Subagent:* `Next`" not in out + + +def test_transcript_unmatched_non_task_tool_result_does_not_pop_subagent(): + state = SubagentState() + state.push("task_1", SubagentSegment("Outer")) + + assert not state.close_for_tool_result("totally_unrelated", tool_name=None) + assert state.open_ids == ("task_1",) + + +def test_transcript_sequential_tasks_mismatched_results_no_depth_drift(): + t = TranscriptBuffer() + t.apply( + { + "type": "tool_use", + "id": "task_1", + "name": "Task", + "input": {"description": "A"}, + } + ) + t.apply({"type": "tool_result", "tool_use_id": "task_1_result", "content": "done"}) + t.apply( + { + "type": "tool_use", + "id": "task_2", + "name": "Task", + "input": {"description": "B"}, + } + ) + t.apply({"type": "tool_result", "tool_use_id": "task_2_result", "content": "done"}) + t.apply( + { + "type": "tool_use", + "id": "task_3", + "name": "Task", + "input": {"description": "C"}, + } + ) + t.apply({"type": "text_chunk", "text": "still hidden inside task three"}) + + out = t.render(_ctx(), limit_chars=3900, status=None) + assert "🤖 *Subagent:* `A`\n 🤖 *Subagent:* `B`" not in out + assert "\n 🤖 *Subagent:* `C`" not in out + assert "still hidden inside task three" not in out + + +def test_transcript_synthetic_task_start_closes_on_functions_task_result_id(): + t = TranscriptBuffer() + t.apply( + { + "type": "tool_use_start", + "index": 0, + "id": "", + "name": "Task", + "input": {"description": "Outer"}, + } + ) + t.apply({"type": "tool_result", "tool_use_id": "functions.Task:0", "content": "x"}) + t.apply( + { + "type": "tool_use_start", + "index": 1, + "id": "", + "name": "Task", + "input": {"description": "Next"}, + } + ) + + out = t.render(_ctx(), limit_chars=3900, status=None) + assert out.count("Subagent:") == 2 + assert "\n 🤖 *Subagent:* `Next`" not in out + + +def test_transcript_synthetic_task_not_closed_by_unknown_non_task_result_id(): + t = TranscriptBuffer() + t.apply( + { + "type": "tool_use_start", + "index": 0, + "id": "", + "name": "Task", + "input": {"description": "Outer"}, + } + ) + t.apply({"type": "tool_result", "tool_use_id": "call_deadbeef", "content": "x"}) + t.apply({"type": "text_chunk", "text": "hidden while synthetic task is open"}) + + out = t.render(_ctx(), limit_chars=3900, status=None) + assert "hidden while synthetic task is open" not in out + + +def test_transcript_overlapping_tasks_are_flat_not_nested(): + t = TranscriptBuffer() + t.apply( + { + "type": "tool_use", + "id": "task_a", + "name": "Task", + "input": {"description": "A"}, + } + ) + t.apply( + { + "type": "tool_use", + "id": "task_b", + "name": "Task", + "input": {"description": "B"}, + } + ) + t.apply({"type": "tool_result", "tool_use_id": "task_b", "content": "done"}) + t.apply({"type": "tool_result", "tool_use_id": "task_a", "content": "done"}) + + out = t.render(_ctx(), limit_chars=3900, status=None) + assert "🤖 *Subagent:* `A`" in out + assert "🤖 *Subagent:* `B`" in out + assert out.find("🤖 *Subagent:* `A`") < out.find("🤖 *Subagent:* `B`") + assert "\n 🤖 *Subagent:* `B`" not in out + + +def test_transcript_truncates_by_dropping_oldest_segments(): + t = TranscriptBuffer() + + # Create many segments by opening/closing distinct text blocks. + for i in range(60): + t.apply({"type": "text_start", "index": i}) + t.apply( + {"type": "text_delta", "index": i, "text": f"segment_{i} " + ("x" * 120)} + ) + t.apply({"type": "block_stop", "index": i}) + + out = t.render(_ctx(), limit_chars=600, status="status") + assert escape_md_v2("... (truncated)") in out + # We keep the tail and drop the oldest segments when truncating. + assert escape_md_v2("segment_59") in out + assert escape_md_v2("segment_0") not in out + + +def test_transcript_render_many_segments_completes_quickly(): + """Render with 200+ segments exercises O(n) truncation (deque popleft).""" + t = TranscriptBuffer() + for i in range(200): + t.apply({"type": "text_start", "index": i}) + t.apply({"type": "text_delta", "index": i, "text": f"seg_{i} " + ("y" * 80)}) + t.apply({"type": "block_stop", "index": i}) + + out = t.render(_ctx(), limit_chars=500, status="ok") + assert escape_md_v2("... (truncated)") in out + assert "199" in out # last segment (MarkdownV2 escapes underscores) + assert "seg_0 " not in out # oldest segment dropped + + +def test_transcript_reused_index_closes_previous_open_block(): + t = TranscriptBuffer() + # Open a text block at index 0, but never close it. + t.apply({"type": "text_start", "index": 0}) + t.apply({"type": "text_delta", "index": 0, "text": "alpha visible"}) + # Provider reuses index 0 for a new tool block without a stop. + t.apply( + {"type": "tool_use_start", "index": 0, "id": "t1", "name": "ls", "input": {}} + ) + t.apply({"type": "text_delta", "index": 0, "text": "omega visible"}) + + out = t.render(_ctx(), limit_chars=3900, status=None) + assert out.find("alpha") < out.find("Tool call:") < out.find("omega") + + +def test_transcript_render_segment_exception_skipped(): + """When a segment's render() raises, that segment is skipped and rest is rendered.""" + + class StaticSegment(Segment): + def __init__(self, text: str) -> None: + super().__init__(kind="static") + self._text = text + + def render(self, ctx: RenderCtx) -> str: + return self._text + + class BrokenSegment(Segment): + def __init__(self) -> None: + super().__init__(kind="broken") + + def render(self, ctx: RenderCtx) -> str: + raise ValueError("render failed") + + out = render_segments( + [StaticSegment("before"), BrokenSegment(), StaticSegment("after")], + _ctx(), + limit_chars=3900, + status=None, + ) + assert "before" in out + assert "after" in out + + +def test_transcript_render_status_only_exceeds_limit(): + """When all segments dropped, status-only output; long status returned as-is.""" + t = TranscriptBuffer() + t.apply({"type": "text_chunk", "text": "x" * 5000}) + + long_status = "A" * 500 + msg = t.render(_ctx(), limit_chars=100, status=long_status) + assert "... (truncated)" in msg or long_status in msg + + +def test_transcript_truncation_preserves_last_segment_tail(): + """When all segments exceed limit, preserve tail of last segment (not just marker+status).""" + t = TranscriptBuffer() + t.apply({"type": "thinking_chunk", "text": "Thinking..."}) + t.apply( + {"type": "text_chunk", "text": "The actual output content here" + "x" * 500} + ) + + msg = t.render(_ctx(), limit_chars=100, status="✅ *Complete*") + # Must include actual content (tail of last segment), not only "... (truncated)\n✅ *Complete*" + assert escape_md_v2("... (truncated)") in msg + assert "✅ *Complete*" in msg + assert "actual output" in msg or "content" in msg or "x" in msg diff --git a/tests/messaging/test_transcription.py b/tests/messaging/test_transcription.py new file mode 100644 index 0000000..2b8ebf3 --- /dev/null +++ b/tests/messaging/test_transcription.py @@ -0,0 +1,262 @@ +"""Tests for the instance-owned local Whisper transcriber.""" + +import asyncio +import threading +import time +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from free_claude_code.messaging.transcription import TranscriptionService + + +def _service(*, api_key: str = "") -> TranscriptionService: + return TranscriptionService( + model="base", + device="cpu", + huggingface_api_key=api_key, + ) + + +def _fake_optional_modules( + pipeline: MagicMock, +) -> tuple[SimpleNamespace, SimpleNamespace, MagicMock, MagicMock]: + torch = SimpleNamespace( + cuda=SimpleNamespace(is_available=lambda: False), + float16=object(), + float32=object(), + ) + + model = MagicMock() + model.to.return_value = model + model_loader = MagicMock() + model_loader.from_pretrained.return_value = model + processor = SimpleNamespace(tokenizer=object(), feature_extractor=object()) + processor_loader = MagicMock() + processor_loader.from_pretrained.return_value = processor + + transformers = SimpleNamespace( + AutoModelForSpeechSeq2Seq=model_loader, + AutoProcessor=processor_loader, + pipeline=MagicMock(return_value=pipeline), + ) + return torch, transformers, model_loader, processor_loader + + +@pytest.mark.asyncio +async def test_transcription_service_transcribes_and_reuses_its_pipeline( + tmp_path: Path, +) -> None: + audio_path = tmp_path / "voice.ogg" + audio_path.write_bytes(b"voice") + pipeline = MagicMock(return_value={"text": " Hello world "}) + torch, transformers, model_loader, processor_loader = _fake_optional_modules( + pipeline + ) + fake_audio = {"array": [0.0], "sampling_rate": 16000} + service = _service(api_key="hf-provider-key") + + with ( + patch.dict( + "sys.modules", + {"torch": torch, "transformers": transformers}, + ), + patch( + "free_claude_code.messaging.transcription._load_audio", + return_value=fake_audio, + ), + ): + first = await service.transcribe(audio_path) + second = await service.transcribe(audio_path) + + assert first == "Hello world" + assert second == "Hello world" + assert transformers.pipeline.call_count == 1 + assert pipeline.call_count == 2 + model_loader.from_pretrained.assert_called_once_with( + "openai/whisper-base", + dtype=torch.float32, + low_cpu_mem_usage=True, + attn_implementation="sdpa", + token="hf-provider-key", + ) + processor_loader.from_pretrained.assert_called_once_with( + "openai/whisper-base", + token="hf-provider-key", + ) + + +@pytest.mark.asyncio +async def test_separate_services_do_not_share_pipeline_instances( + tmp_path: Path, +) -> None: + audio_path = tmp_path / "voice.wav" + audio_path.write_bytes(b"voice") + pipeline = MagicMock(return_value={"text": "ok"}) + torch, transformers, _model_loader, _processor_loader = _fake_optional_modules( + pipeline + ) + first = _service() + second = _service() + + with ( + patch.dict( + "sys.modules", + {"torch": torch, "transformers": transformers}, + ), + patch( + "free_claude_code.messaging.transcription._load_audio", + return_value={"array": [0.0], "sampling_rate": 16000}, + ), + ): + await first.transcribe(audio_path) + await second.transcribe(audio_path) + + assert transformers.pipeline.call_count == 2 + + +@pytest.mark.asyncio +async def test_transcription_service_serializes_concurrent_inference( + tmp_path: Path, +) -> None: + service = _service() + audio_path = tmp_path / "voice.wav" + audio_path.write_bytes(b"voice") + state_lock = threading.Lock() + active = 0 + max_active = 0 + + def transcribe_sync(_path: Path) -> str: + nonlocal active, max_active + with state_lock: + active += 1 + max_active = max(max_active, active) + time.sleep(0.02) + with state_lock: + active -= 1 + return "ok" + + with patch.object(service, "_transcribe_sync", side_effect=transcribe_sync): + results = await asyncio.gather( + service.transcribe(audio_path), + service.transcribe(audio_path), + service.transcribe(audio_path), + ) + + assert results == ["ok", "ok", "ok"] + assert max_active == 1 + + +@pytest.mark.asyncio +async def test_transcription_service_close_releases_pipeline_and_is_terminal( + tmp_path: Path, +) -> None: + service = _service() + audio_path = tmp_path / "voice.wav" + audio_path.write_bytes(b"voice") + pipeline = MagicMock(return_value={"text": "ok"}) + + with ( + patch.object(service, "_get_pipeline", return_value=pipeline), + patch( + "free_claude_code.messaging.transcription._load_audio", + return_value={"array": [0.0], "sampling_rate": 16000}, + ), + ): + await service.transcribe(audio_path) + + service._pipeline = pipeline + await service.close() + await service.close() + + assert service._pipeline is None + with pytest.raises(RuntimeError, match="closed"): + await service.transcribe(audio_path) + + +@pytest.mark.asyncio +async def test_cancelled_transcription_keeps_ownership_until_thread_exits( + tmp_path: Path, +) -> None: + service = _service(api_key="hf-secret") + audio_path = tmp_path / "voice.wav" + audio_path.write_bytes(b"voice") + started = threading.Event() + release = threading.Event() + pipeline = object() + + def blocking_transcribe(_path: Path) -> str: + started.set() + if not release.wait(timeout=5): + raise TimeoutError("test did not release active transcription") + service._pipeline = pipeline + return "finished" + + close_task: asyncio.Task[None] | None = None + with patch.object(service, "_transcribe_sync", side_effect=blocking_transcribe): + transcribe_task = asyncio.create_task(service.transcribe(audio_path)) + try: + assert await asyncio.to_thread(started.wait, 2) + transcribe_task.cancel() + await asyncio.sleep(0) + close_task = asyncio.create_task(service.close()) + await asyncio.sleep(0) + + assert not transcribe_task.done() + assert not close_task.done() + assert service._huggingface_api_key == "hf-secret" + finally: + release.set() + + with pytest.raises(asyncio.CancelledError): + await transcribe_task + assert close_task is not None + await close_task + + assert service._pipeline is None + assert service._huggingface_api_key == "" + with pytest.raises(RuntimeError, match="closed"): + await service.transcribe(audio_path) + + +@pytest.mark.asyncio +async def test_transcription_service_returns_no_speech_placeholder( + tmp_path: Path, +) -> None: + service = _service() + audio_path = tmp_path / "voice.ogg" + audio_path.write_bytes(b"voice") + pipeline = MagicMock(return_value={"text": []}) + + with ( + patch.object(service, "_get_pipeline", return_value=pipeline), + patch( + "free_claude_code.messaging.transcription._load_audio", + return_value={"array": [0.0], "sampling_rate": 16000}, + ), + ): + result = await service.transcribe(audio_path) + + assert result == "(no speech detected)" + + +def test_transcription_service_rejects_non_local_device() -> None: + with pytest.raises(ValueError, match="must be 'cpu' or 'cuda'"): + TranscriptionService(model="base", device="nvidia_nim") + + +@pytest.mark.asyncio +async def test_transcription_service_reports_missing_local_extra( + tmp_path: Path, +) -> None: + service = _service() + audio_path = tmp_path / "voice.ogg" + audio_path.write_bytes(b"voice") + + with ( + patch.dict("sys.modules", {"torch": None}), + pytest.raises(ImportError, match="voice_local extra"), + ): + await service.transcribe(audio_path) diff --git a/tests/messaging/test_transcription_nim.py b/tests/messaging/test_transcription_nim.py new file mode 100644 index 0000000..39e77ea --- /dev/null +++ b/tests/messaging/test_transcription_nim.py @@ -0,0 +1,244 @@ +"""Tests for the NVIDIA NIM voice transcription adapter.""" + +import asyncio +from pathlib import Path +from threading import Event +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from free_claude_code.providers.nvidia_nim.voice import ( + _NIM_ASR_MODEL_MAP, + NvidiaNimTranscriber, +) + + +def _fake_riva_client( + transcript: str, +) -> tuple[SimpleNamespace, SimpleNamespace, MagicMock, MagicMock]: + response = SimpleNamespace( + results=[ + SimpleNamespace( + alternatives=[SimpleNamespace(transcript=transcript)], + ) + ] + ) + asr_service = MagicMock() + asr_service.offline_recognize.return_value = response + auth = MagicMock() + + client = SimpleNamespace( + Auth=MagicMock(return_value=auth), + ASRService=MagicMock(return_value=asr_service), + RecognitionConfig=MagicMock(return_value=object()), + ) + riva = SimpleNamespace(__path__=[], client=client) + return riva, client, asr_service, auth + + +@pytest.mark.asyncio +async def test_nvidia_nim_transcriber_calls_riva_with_owned_configuration( + tmp_path: Path, +) -> None: + wav = tmp_path / "stub.wav" + wav.write_bytes(b"audio bytes") + transcriber = NvidiaNimTranscriber( + model="openai/whisper-large-v3", + api_key=" test-nim-key ", + ) + riva, client, asr_service, auth = _fake_riva_client(" hello from NIM ") + + with patch.dict( + "sys.modules", + {"riva": riva, "riva.client": client}, + ): + result = await transcriber.transcribe(wav) + + assert result == " hello from NIM " + client.Auth.assert_called_once_with( + use_ssl=True, + uri="grpc.nvcf.nvidia.com:443", + metadata_args=[ + ["function-id", "b702f636-f60c-4a3d-a6f4-f3568c13bd7d"], + ["authorization", "Bearer test-nim-key"], + ], + ) + client.RecognitionConfig.assert_called_once_with( + language_code="multi", + max_alternatives=1, + verbatim_transcripts=True, + ) + asr_service.offline_recognize.assert_called_once_with( + b"audio bytes", + client.RecognitionConfig.return_value, + ) + auth.channel.close.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_nvidia_nim_transcriber_closes_channel_when_recognition_fails( + tmp_path: Path, +) -> None: + wav = tmp_path / "stub.wav" + wav.write_bytes(b"audio bytes") + transcriber = NvidiaNimTranscriber( + model="openai/whisper-large-v3", + api_key="test-nim-key", + ) + riva, client, asr_service, auth = _fake_riva_client("") + asr_service.offline_recognize.side_effect = RuntimeError("recognition failed") + + with ( + patch.dict( + "sys.modules", + {"riva": riva, "riva.client": client}, + ), + pytest.raises(RuntimeError, match="recognition failed"), + ): + await transcriber.transcribe(wav) + + auth.channel.close.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_nvidia_nim_transcriber_validates_key_and_model_before_import( + tmp_path: Path, +) -> None: + wav = tmp_path / "stub.wav" + wav.write_bytes(b"audio") + + with pytest.raises(ValueError, match="non-empty"): + await NvidiaNimTranscriber( + model="openai/whisper-large-v3", + api_key="", + ).transcribe(wav) + + with pytest.raises(ValueError, match="No NVIDIA NIM config"): + await NvidiaNimTranscriber( + model="unknown/model", + api_key="key", + ).transcribe(wav) + + +@pytest.mark.asyncio +async def test_nvidia_nim_transcriber_close_is_terminal(tmp_path: Path) -> None: + wav = tmp_path / "stub.wav" + wav.write_bytes(b"audio") + transcriber = NvidiaNimTranscriber( + model="openai/whisper-large-v3", + api_key="key", + ) + + await transcriber.close() + await transcriber.close() + + with pytest.raises(RuntimeError, match="closed"): + await transcriber.transcribe(wav) + + +@pytest.mark.asyncio +async def test_nvidia_nim_transcriber_close_waits_for_active_work( + tmp_path: Path, +) -> None: + wav = tmp_path / "stub.wav" + wav.write_bytes(b"audio") + transcriber = NvidiaNimTranscriber( + model="openai/whisper-large-v3", + api_key="secret-key", + ) + started = Event() + release = Event() + + def blocking_transcribe(_file_path: Path) -> str: + started.set() + if not release.wait(timeout=5): + raise TimeoutError("test did not release active transcription") + return "finished" + + close_task: asyncio.Task[None] | None = None + with patch.object( + transcriber, + "_transcribe_sync", + side_effect=blocking_transcribe, + ): + transcribe_task = asyncio.create_task(transcriber.transcribe(wav)) + try: + assert await asyncio.to_thread(started.wait, 2) + close_task = asyncio.create_task(transcriber.close()) + await asyncio.sleep(0) + + assert not close_task.done() + finally: + release.set() + transcript = await transcribe_task + if close_task is not None: + await close_task + + assert transcript == "finished" + assert transcriber._key == "" + with pytest.raises(RuntimeError, match="closed"): + await transcriber.transcribe(wav) + + +@pytest.mark.asyncio +async def test_cancelled_nim_transcription_keeps_key_until_thread_exits( + tmp_path: Path, +) -> None: + wav = tmp_path / "stub.wav" + wav.write_bytes(b"audio") + transcriber = NvidiaNimTranscriber( + model="openai/whisper-large-v3", + api_key="secret-key", + ) + started = Event() + release = Event() + observed_keys: list[str] = [] + + def blocking_transcribe(_file_path: Path) -> str: + observed_keys.append(transcriber._key) + started.set() + if not release.wait(timeout=5): + raise TimeoutError("test did not release active transcription") + observed_keys.append(transcriber._key) + return "finished" + + close_task: asyncio.Task[None] | None = None + with patch.object( + transcriber, + "_transcribe_sync", + side_effect=blocking_transcribe, + ): + transcribe_task = asyncio.create_task(transcriber.transcribe(wav)) + try: + assert await asyncio.to_thread(started.wait, 2) + transcribe_task.cancel() + await asyncio.sleep(0) + close_task = asyncio.create_task(transcriber.close()) + await asyncio.sleep(0) + + assert not transcribe_task.done() + assert not close_task.done() + assert transcriber._key == "secret-key" + finally: + release.set() + + with pytest.raises(asyncio.CancelledError): + await transcribe_task + assert close_task is not None + await close_task + + assert observed_keys == ["secret-key", "secret-key"] + assert transcriber._key == "" + with pytest.raises(RuntimeError, match="closed"): + await transcriber.transcribe(wav) + + +def test_nim_asr_model_map_entries_are_real_function_ids() -> None: + for function_id, language_code in _NIM_ASR_MODEL_MAP.values(): + assert function_id + assert function_id.strip().lower() != "none" + parts = function_id.split("-") + assert len(parts) == 5 + assert all(parts) + assert language_code is not None diff --git a/tests/messaging/test_tree_concurrency.py b/tests/messaging/test_tree_concurrency.py new file mode 100644 index 0000000..bd0e951 --- /dev/null +++ b/tests/messaging/test_tree_concurrency.py @@ -0,0 +1,308 @@ +"""Deterministic manager concurrency contracts.""" + +import asyncio + +import pytest + +from free_claude_code.messaging.models import IncomingMessage, MessageScope +from free_claude_code.messaging.trees import ( + CancellationReason, + CancellationUiOwner, + NodeClaim, + QueueEntry, + TreeQueueManager, +) + +_SCOPE = MessageScope(platform="telegram", chat_id="chat") + + +def _incoming(node_id: str, *, reply_to: str | None = None) -> IncomingMessage: + return IncomingMessage( + text=f"prompt {node_id}", + chat_id=_SCOPE.chat_id, + user_id="user", + message_id=node_id, + platform=_SCOPE.platform, + reply_to_message_id=reply_to, + ) + + +async def _wait_for_no_tasks(manager: TreeQueueManager) -> None: + loop = asyncio.get_running_loop() + for _ in range(30): + if manager.task_count() == 0: + return + checkpoint = asyncio.Event() + loop.call_soon(checkpoint.set) + await checkpoint.wait() + assert manager.task_count() == 0 + + +@pytest.mark.asyncio +async def test_one_tree_processes_fifo_with_transition_owned_queue_updates() -> None: + node_ids = ("root", "a", "b", "c") + releases = {node_id: asyncio.Event() for node_id in node_ids} + completions = {node_id: asyncio.Event() for node_id in node_ids} + started: asyncio.Queue[str] = asyncio.Queue() + started_callbacks: list[str] = [] + queue_updates: list[tuple[tuple[str, int], ...]] = [] + manager: TreeQueueManager + + async def process(claim: NodeClaim) -> None: + node_id = claim.node.node_id + started.put_nowait(node_id) + await releases[node_id].wait() + await manager.complete_claim(claim, f"session-{node_id}") + completions[node_id].set() + + async def capture_started(claim: NodeClaim) -> None: + started_callbacks.append(claim.node.node_id) + + async def capture_queue(queue: tuple[QueueEntry, ...]) -> None: + queue_updates.append( + tuple((entry.node.node_id, entry.position) for entry in queue) + ) + + manager = TreeQueueManager( + process, + queue_update_callback=capture_queue, + node_started_callback=capture_started, + ) + root = await manager.admit(_incoming("root"), "status-root") + assert root.claim is not None + identity = root.claim.identity + assert await started.get() == "root" + + decisions = [ + await manager.admit( + _incoming(node_id, reply_to="root"), + f"status-{node_id}", + parent_reference_id="root", + ) + for node_id in node_ids[1:] + ] + assert [decision.position for decision in decisions] == [1, 2, 3] + + observed = ["root"] + for node_id in node_ids: + releases[node_id].set() + await completions[node_id].wait() + if node_id != node_ids[-1]: + observed.append(await started.get()) + + await _wait_for_no_tasks(manager) + + assert observed == list(node_ids) + assert started_callbacks == ["a", "b", "c"] + assert queue_updates == [ + (("b", 1), ("c", 2)), + (("c", 1),), + (), + ] + snapshot = await manager.snapshot() + assert { + node_id: snapshot.trees[identity].nodes[node_id]["state"] + for node_id in node_ids + } == dict.fromkeys(node_ids, "completed") + + +@pytest.mark.asyncio +async def test_separate_trees_process_in_parallel() -> None: + started = {node_id: asyncio.Event() for node_id in ("one", "two")} + releases = {node_id: asyncio.Event() for node_id in ("one", "two")} + completed = {node_id: asyncio.Event() for node_id in ("one", "two")} + all_started = asyncio.Event() + active = 0 + maximum_active = 0 + manager: TreeQueueManager + + async def process(claim: NodeClaim) -> None: + nonlocal active, maximum_active + node_id = claim.node.node_id + active += 1 + maximum_active = max(maximum_active, active) + started[node_id].set() + if all(event.is_set() for event in started.values()): + all_started.set() + try: + await releases[node_id].wait() + await manager.complete_claim(claim, f"session-{node_id}") + finally: + active -= 1 + completed[node_id].set() + + manager = TreeQueueManager(process) + await asyncio.gather( + manager.admit(_incoming("one"), "status-one"), + manager.admit(_incoming("two"), "status-two"), + ) + await all_started.wait() + + assert maximum_active == 2 + assert active == 2 + assert manager.get_tree_count() == 2 + + releases["one"].set() + releases["two"].set() + await asyncio.gather(*(event.wait() for event in completed.values())) + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +async def test_cancel_all_cancels_active_and_queued_work_across_trees() -> None: + active_started = {node_id: asyncio.Event() for node_id in ("one", "two")} + processed: list[str] = [] + + async def process(claim: NodeClaim) -> None: + node_id = claim.node.node_id + processed.append(node_id) + if node_id in active_started: + active_started[node_id].set() + await asyncio.Event().wait() + + manager = TreeQueueManager(process) + await manager.admit(_incoming("one"), "status-one") + await manager.admit(_incoming("two"), "status-two") + await asyncio.gather(*(event.wait() for event in active_started.values())) + await manager.admit( + _incoming("one-child", reply_to="one"), + "status-one-child", + parent_reference_id="one", + ) + await manager.admit( + _incoming("two-child", reply_to="two"), + "status-two-child", + parent_reference_id="two", + ) + + result = await manager.cancel_all(reason=CancellationReason.STOP) + + owners = {effect.node.node_id: effect.ui_owner for effect in result.effects} + assert owners == { + "one": CancellationUiOwner.RUNNER, + "one-child": CancellationUiOwner.WORKFLOW, + "two": CancellationUiOwner.RUNNER, + "two-child": CancellationUiOwner.WORKFLOW, + } + assert len(result.snapshots) == 2 + assert { + node["state"] + for snapshot in result.snapshots + for node in snapshot.nodes.values() + } == {"error"} + assert set(processed) == {"one", "two"} + assert manager.task_count() == 0 + + +@pytest.mark.asyncio +async def test_branch_removal_atomically_unindexes_subtree_and_preserves_sibling() -> ( + None +): + root_started = asyncio.Event() + release_root = asyncio.Event() + sibling_started = asyncio.Event() + release_sibling = asyncio.Event() + unexpected: list[str] = [] + + async def process(claim: NodeClaim) -> None: + node_id = claim.node.node_id + if node_id == "root": + root_started.set() + await release_root.wait() + elif node_id == "sibling": + sibling_started.set() + await release_sibling.wait() + else: + unexpected.append(node_id) + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + await root_started.wait() + await manager.admit( + _incoming("branch", reply_to="root"), + "status-branch", + parent_reference_id="root", + ) + await manager.admit( + _incoming("leaf", reply_to="branch"), + "status-leaf", + parent_reference_id="branch", + ) + await manager.admit( + _incoming("sibling", reply_to="root"), + "status-sibling", + parent_reference_id="root", + ) + + result = await manager.remove_message_subtree( + _SCOPE, + "branch", + reason=CancellationReason.STOP, + ) + + assert result.removed_tree_identity is None + assert result.delete_message_ids == frozenset( + {"branch", "status-branch", "leaf", "status-leaf"} + ) + assert { + effect.node.node_id: effect.ui_owner for effect in result.cancellation.effects + } == { + "branch": CancellationUiOwner.WORKFLOW, + "leaf": CancellationUiOwner.WORKFLOW, + } + assert len(result.cancellation.snapshots) == 1 + assert set(result.cancellation.snapshots[0].nodes) == {"root", "sibling"} + assert await manager.resolve_node_id(_SCOPE, "branch") is None + assert await manager.resolve_node_id(_SCOPE, "status-leaf") is None + assert await manager.resolve_node_id(_SCOPE, "sibling") == "sibling" + + release_root.set() + await asyncio.wait_for(sibling_started.wait(), timeout=1) + assert unexpected == [] + release_sibling.set() + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +async def test_root_removal_atomically_cancels_and_unindexes_entire_tree() -> None: + root_started = asyncio.Event() + processed: list[str] = [] + + async def process(claim: NodeClaim) -> None: + processed.append(claim.node.node_id) + root_started.set() + await asyncio.Event().wait() + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + await root_started.wait() + await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + + result = await manager.remove_message_subtree( + _SCOPE, + "root", + reason=CancellationReason.STOP, + ) + + assert result.removed_tree_identity is not None + assert result.removed_tree_identity.scope == _SCOPE + assert result.removed_tree_identity.root_id == "root" + assert result.delete_message_ids == frozenset( + {"root", "status-root", "child", "status-child"} + ) + assert { + effect.node.node_id: effect.ui_owner for effect in result.cancellation.effects + } == { + "root": CancellationUiOwner.RUNNER, + "child": CancellationUiOwner.WORKFLOW, + } + assert result.cancellation.snapshots == () + assert manager.get_tree_count() == 0 + assert manager.task_count() == 0 + assert await manager.resolve_node_id(_SCOPE, "root") is None + assert await manager.resolve_node_id(_SCOPE, "status-child") is None + assert processed == ["root"] diff --git a/tests/messaging/test_tree_customer_regressions.py b/tests/messaging/test_tree_customer_regressions.py new file mode 100644 index 0000000..45d7e66 --- /dev/null +++ b/tests/messaging/test_tree_customer_regressions.py @@ -0,0 +1,113 @@ +"""Customer-visible regressions at the tree manager ownership boundary.""" + +import asyncio + +import pytest + +from free_claude_code.messaging.models import IncomingMessage, MessageScope +from free_claude_code.messaging.trees import ( + ConversationSnapshot, + MessageState, + NodeClaim, + TreeIdentity, + TreeQueueManager, +) + + +def _incoming(chat_id: str) -> IncomingMessage: + return IncomingMessage( + text=f"prompt from {chat_id}", + chat_id=chat_id, + user_id=f"user-{chat_id}", + message_id="42", + platform="telegram", + ) + + +async def _wait_for_no_tasks(manager: TreeQueueManager) -> None: + for _ in range(100): + if manager.task_count() == 0: + return + await asyncio.sleep(0) + raise AssertionError("messaging claims did not finish") + + +def _snapshot_chat_ids(snapshot: ConversationSnapshot) -> set[str]: + return {tree.scope.chat_id for tree in snapshot.trees.values()} + + +@pytest.mark.asyncio +async def test_same_telegram_ids_in_different_chats_remain_independent_after_restore() -> ( + None +): + async def process(_claim: NodeClaim) -> None: + return + + manager = TreeQueueManager(process) + + first = await manager.admit(_incoming("chat-a"), "99") + second = await manager.admit(_incoming("chat-b"), "99") + await _wait_for_no_tasks(manager) + + assert first.accepted is True + assert second.accepted is True + assert manager.get_tree_count() == 2 + + snapshot = await manager.snapshot() + assert len(snapshot.trees) == 2 + assert _snapshot_chat_ids(snapshot) == {"chat-a", "chat-b"} + chat_a = MessageScope(platform="telegram", chat_id="chat-a") + chat_b = MessageScope(platform="telegram", chat_id="chat-b") + assert snapshot.get_tree(TreeIdentity(scope=chat_a, root_id="42")) is not None + assert snapshot.get_tree(TreeIdentity(scope=chat_b, root_id="42")) is not None + + restored = TreeQueueManager.from_snapshot(snapshot, process) + + assert restored.get_tree_count() == 2 + assert _snapshot_chat_ids(await restored.snapshot()) == {"chat-a", "chat-b"} + assert await restored.get_message_ids_for_chat("telegram", "chat-a") == {"42", "99"} + assert await restored.get_message_ids_for_chat("telegram", "chat-b") == {"42", "99"} + assert await restored.get_node(chat_a, "42") is not None + assert await restored.get_node(chat_b, "42") is not None + + +@pytest.mark.asyncio +async def test_successful_completion_overrides_recoverable_failure_and_records_session() -> ( + None +): + started = asyncio.Event() + release = asyncio.Event() + + async def process(_claim: NodeClaim) -> None: + started.set() + await release.wait() + + manager = TreeQueueManager(process) + decision = await manager.admit(_incoming("chat"), "99") + assert decision.claim is not None + await started.wait() + + try: + failure = await manager.fail_claim( + decision.claim, + propagate=True, + ) + assert failure.snapshot is not None + failed_node = next(iter(failure.snapshot.nodes.values())) + assert failed_node["state"] == MessageState.ERROR.value + + completed = await manager.complete_claim(decision.claim, "session-42") + + assert completed is not None + completed_node = next(iter(completed.nodes.values())) + assert completed_node["state"] == MessageState.COMPLETED.value + assert completed_node["session_id"] == "session-42" + + persisted = await manager.snapshot() + persisted_node = next(iter(persisted.trees.values())).nodes.values() + persisted_node = next(iter(persisted_node)) + assert persisted_node["state"] == MessageState.COMPLETED.value + assert persisted_node["session_id"] == "session-42" + finally: + release.set() + await _wait_for_no_tasks(manager) diff --git a/tests/messaging/test_tree_ownership_concurrency.py b/tests/messaging/test_tree_ownership_concurrency.py new file mode 100644 index 0000000..e324c9a --- /dev/null +++ b/tests/messaging/test_tree_ownership_concurrency.py @@ -0,0 +1,610 @@ +import asyncio +import contextlib + +import pytest + +from free_claude_code.messaging.models import IncomingMessage, MessageScope +from free_claude_code.messaging.trees import manager as manager_module +from free_claude_code.messaging.trees.manager import TreeQueueManager +from free_claude_code.messaging.trees.transitions import ( + AdmissionRejection, + CancellationReason, + CancellationUiOwner, + NodeClaim, +) + +_SCOPE = MessageScope(platform="telegram", chat_id="chat") + + +def _incoming(node_id: str, *, reply_to: str | None = None) -> IncomingMessage: + return IncomingMessage( + text=node_id, + chat_id=_SCOPE.chat_id, + user_id="user", + message_id=node_id, + platform=_SCOPE.platform, + reply_to_message_id=reply_to, + ) + + +async def _wait_for_no_tasks(manager: TreeQueueManager) -> None: + loop = asyncio.get_running_loop() + for _ in range(20): + if manager.task_count() == 0: + return + checkpoint = asyncio.Event() + loop.call_soon(checkpoint.set) + await checkpoint.wait() + assert manager.task_count() == 0 + + +@pytest.mark.asyncio +async def test_cancelled_finisher_cannot_erase_or_overlap_a_new_claim() -> None: + root_started = asyncio.Event() + cancellation_seen = asyncio.Event() + release_cleanup = asyncio.Event() + child_started = asyncio.Event() + release_child = asyncio.Event() + active = 0 + maximum_active = 0 + + async def process(claim: NodeClaim) -> None: + nonlocal active, maximum_active + active += 1 + maximum_active = max(maximum_active, active) + try: + if claim.node.node_id == "root": + root_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancellation_seen.set() + await release_cleanup.wait() + raise + child_started.set() + await release_child.wait() + finally: + active -= 1 + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + await root_started.wait() + + cancellation = asyncio.create_task( + manager.cancel_node(_SCOPE, "root", reason=CancellationReason.STOP) + ) + await cancellation_seen.wait() + child = await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + + assert child.position == 1 + assert not child_started.is_set() + assert maximum_active == 1 + + release_cleanup.set() + await cancellation + await asyncio.wait_for(child_started.wait(), timeout=1) + assert maximum_active == 1 + + release_child.set() + + +@pytest.mark.asyncio +async def test_cancelled_runner_exception_cannot_fail_or_skip_queued_claim() -> None: + root_started = asyncio.Event() + child_started = asyncio.Event() + release_child = asyncio.Event() + unexpected_failures = [] + + async def process(claim: NodeClaim) -> None: + if claim.node.node_id == "root": + root_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + raise RuntimeError("runner escaped after cancellation") from None + child_started.set() + await release_child.wait() + + manager = TreeQueueManager( + process, + unexpected_failure_callback=unexpected_failures.append, + ) + await manager.admit(_incoming("root"), "status-root") + await root_started.wait() + await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + + try: + await manager.cancel_node(_SCOPE, "root", reason=CancellationReason.STOP) + await asyncio.wait_for(child_started.wait(), timeout=1) + + assert len(unexpected_failures) == 1 + assert unexpected_failures[0].affected == () + assert unexpected_failures[0].queue_update is None + assert unexpected_failures[0].snapshot is None + finally: + release_child.set() + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "operation", + ["global_stop", "global_clear", "branch_clear"], +) +async def test_terminal_operation_serializes_with_successor_task_publication( + monkeypatch: pytest.MonkeyPatch, + operation: str, +) -> None: + root_started = asyncio.Event() + release_root = asyncio.Event() + successor_selected = asyncio.Event() + release_successor_publication = asyncio.Event() + child_started = asyncio.Event() + child_exited = asyncio.Event() + release_child = asyncio.Event() + + async def process(claim: NodeClaim) -> None: + if claim.node.node_id == "root": + root_started.set() + await release_root.wait() + return + child_started.set() + try: + await release_child.wait() + finally: + child_exited.set() + + manager = TreeQueueManager(process) + root = await manager.admit(_incoming("root"), "status-root") + assert root.claim is not None + await root_started.wait() + await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + tree = manager._repository.get_tree(root.claim.identity) + assert tree is not None + original_finish = tree.finish_and_claim_next + + async def pause_after_successor_selection(claim_id: str): + completion = await original_finish(claim_id) + if completion.next_claim is not None: + successor_selected.set() + await release_successor_publication.wait() + return completion + + monkeypatch.setattr( + tree, + "finish_and_claim_next", + pause_after_successor_selection, + ) + operation_task: asyncio.Task | None = None + try: + release_root.set() + await successor_selected.wait() + if operation == "global_stop": + operation_task = asyncio.create_task( + manager.cancel_all(reason=CancellationReason.STOP) + ) + elif operation == "global_clear": + operation_task = asyncio.create_task( + manager.clear_scope(_SCOPE, reason=CancellationReason.STOP) + ) + else: + operation_task = asyncio.create_task( + manager.remove_message_subtree( + _SCOPE, + "root", + reason=CancellationReason.STOP, + ) + ) + for _ in range(5): + await asyncio.sleep(0) + if operation_task.done(): + break + + assert not operation_task.done() + finally: + release_successor_publication.set() + try: + if operation_task is not None: + await operation_task + finally: + release_child.set() + await _wait_for_no_tasks(manager) + + assert manager.get_tree_count() == (1 if operation == "global_stop" else 0) + assert manager.task_count() == 0 + if child_started.is_set(): + assert child_exited.is_set() + + +@pytest.mark.asyncio +async def test_duplicate_admission_is_rejected_and_processed_once() -> None: + started = asyncio.Event() + release = asyncio.Event() + calls: list[str] = [] + + async def process(claim: NodeClaim) -> None: + calls.append(claim.node.node_id) + started.set() + await release.wait() + + manager = TreeQueueManager(process) + first = await manager.admit(_incoming("root"), "status-root") + duplicate = await manager.admit(_incoming("root"), "status-duplicate") + await started.wait() + + assert first.accepted is True + assert duplicate.accepted is False + assert calls == ["root"] + assert manager.task_count() == 1 + + release.set() + + +@pytest.mark.asyncio +async def test_node_and_status_references_are_published_together() -> None: + release = asyncio.Event() + + async def process(_claim: NodeClaim) -> None: + await release.wait() + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + + assert await manager.resolve_node_id(_SCOPE, "root") == "root" + assert await manager.resolve_node_id(_SCOPE, "status-root") == "root" + + release.set() + + +@pytest.mark.asyncio +async def test_callback_failure_does_not_block_the_next_claim() -> None: + release_root = asyncio.Event() + child_started = asyncio.Event() + release_child = asyncio.Event() + + async def process(claim: NodeClaim) -> None: + if claim.node.node_id == "root": + await release_root.wait() + else: + child_started.set() + await release_child.wait() + + async def broken_queue_callback(_queue) -> None: + raise RuntimeError("UI unavailable") + + async def broken_started_callback(_claim) -> None: + raise RuntimeError("UI unavailable") + + manager = TreeQueueManager( + process, + queue_update_callback=broken_queue_callback, + node_started_callback=broken_started_callback, + ) + await manager.admit(_incoming("root"), "status-root") + await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + + release_root.set() + await asyncio.wait_for(child_started.wait(), timeout=1) + release_child.set() + + +@pytest.mark.asyncio +async def test_branch_removal_cannot_leave_a_detached_running_descendant() -> None: + root_started = asyncio.Event() + release_root = asyncio.Event() + + async def process(claim: NodeClaim) -> None: + if claim.node.node_id == "root": + root_started.set() + await release_root.wait() + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + await root_started.wait() + await manager.admit( + _incoming("branch", reply_to="root"), + "status-branch", + parent_reference_id="root", + ) + + removed = await manager.remove_message_subtree(_SCOPE, "branch") + late = await manager.admit( + _incoming("late", reply_to="branch"), + "status-late", + parent_reference_id="branch", + ) + + assert removed.delete_message_ids == frozenset({"branch", "status-branch"}) + assert await manager.resolve_node_id(_SCOPE, "branch") is None + assert late.accepted is False + assert late.rejection is AdmissionRejection.PARENT_REMOVED + assert await manager.get_node(_SCOPE, "late") is None + + release_root.set() + + +@pytest.mark.asyncio +async def test_clear_all_drains_terminal_claim_task_before_returning() -> None: + terminal = asyncio.Event() + release_cleanup = asyncio.Event() + cleanup_finished = asyncio.Event() + manager: TreeQueueManager + + async def process(claim: NodeClaim) -> None: + await manager.complete_claim(claim, "session-root") + terminal.set() + try: + await release_cleanup.wait() + finally: + cleanup_finished.set() + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + await terminal.wait() + + try: + await manager.clear_scope(_SCOPE, reason=CancellationReason.STOP) + + assert cleanup_finished.is_set() + assert manager.task_count() == 0 + assert manager.get_tree_count() == 0 + finally: + release_cleanup.set() + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +async def test_clear_all_returns_committed_result_after_caller_cancellation() -> None: + runner_cancelled = asyncio.Event() + release_cleanup = asyncio.Event() + cleanup_finished = asyncio.Event() + + async def process(_claim: NodeClaim) -> None: + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + runner_cancelled.set() + try: + await release_cleanup.wait() + finally: + cleanup_finished.set() + raise + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + checkpoint = asyncio.Event() + asyncio.get_running_loop().call_soon(checkpoint.set) + await checkpoint.wait() + + clear_task = asyncio.create_task( + manager.clear_scope(_SCOPE, reason=CancellationReason.STOP) + ) + await runner_cancelled.wait() + + try: + clear_task.cancel() + cancellation_checkpoint = asyncio.Event() + asyncio.get_running_loop().call_soon(cancellation_checkpoint.set) + await cancellation_checkpoint.wait() + + assert not clear_task.done() + release_cleanup.set() + result = await clear_task + assert {effect.node.node_id for effect in result.effects} == {"root"} + assert cleanup_finished.is_set() + assert manager.task_count() == 0 + assert manager.get_tree_count() == 0 + finally: + release_cleanup.set() + if not clear_task.done(): + clear_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await clear_task + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +async def test_eager_task_factory_cannot_run_claim_before_admission_returns() -> None: + started = asyncio.Event() + release = asyncio.Event() + + async def process(_claim: NodeClaim) -> None: + started.set() + await release.wait() + + manager = TreeQueueManager(process) + loop = asyncio.get_running_loop() + original_factory = loop.get_task_factory() + try: + loop.set_task_factory(asyncio.eager_task_factory) + decision = await manager.admit(_incoming("root"), "status-root") + finally: + loop.set_task_factory(original_factory) + + try: + assert decision.accepted is True + assert decision.claim is not None + assert decision.claim.node.scope == _SCOPE + assert manager.task_count() == 1 + assert not started.is_set() + await asyncio.sleep(0) + assert started.is_set() + finally: + release.set() + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +async def test_absorbed_pre_run_cancellation_cannot_start_node_processor() -> None: + root_started = asyncio.Event() + release_root = asyncio.Event() + callback_entered = asyncio.Event() + callback_absorbed_cancellation = asyncio.Event() + child_processor_started = asyncio.Event() + + async def process(claim: NodeClaim) -> None: + if claim.node.node_id == "root": + root_started.set() + await release_root.wait() + return + child_processor_started.set() + + async def announce_started(claim: NodeClaim) -> None: + if claim.node.node_id != "child": + return + callback_entered.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + callback_absorbed_cancellation.set() + + manager = TreeQueueManager(process, node_started_callback=announce_started) + await manager.admit(_incoming("root"), "status-root") + await root_started.wait() + child = await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + assert child.position == 1 + + release_root.set() + await callback_entered.wait() + result = await manager.cancel_node( + _SCOPE, + "child", + reason=CancellationReason.STOP, + ) + + assert callback_absorbed_cancellation.is_set() + assert not child_processor_started.is_set() + assert [(effect.node.node_id, effect.ui_owner) for effect in result.effects] == [ + ("child", CancellationUiOwner.WORKFLOW) + ] + node = await manager.get_node(_SCOPE, "child") + assert node is not None and node.state.value == "error" + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +async def test_same_scoped_root_can_be_readmitted_while_detached_claim_finishes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(manager_module, "CANCEL_TASK_DRAIN_TIMEOUT_S", 0.01) + old_started = asyncio.Event() + old_cancelled = asyncio.Event() + release_old = asyncio.Event() + new_started = asyncio.Event() + release_new = asyncio.Event() + claims: list[NodeClaim] = [] + + async def process(claim: NodeClaim) -> None: + claims.append(claim) + if len(claims) == 1: + old_started.set() + while True: + try: + await release_old.wait() + return + except asyncio.CancelledError: + old_cancelled.set() + new_started.set() + await release_new.wait() + + manager = TreeQueueManager(process) + old = await manager.admit(_incoming("root"), "status-root") + await old_started.wait() + + try: + cleared = await manager.clear_scope(_SCOPE, reason=CancellationReason.STOP) + await old_cancelled.wait() + assert {effect.node.node_id for effect in cleared.effects} == {"root"} + assert manager.get_tree_count() == 0 + assert manager.task_count() == 1 + + new = await manager.admit(_incoming("root"), "status-root") + await new_started.wait() + + assert old.claim is not None + assert new.claim is not None + assert new.accepted is True + assert new.claim.identity == old.claim.identity + assert new.claim.claim_id != old.claim.claim_id + assert manager.task_count() == 2 + finally: + release_old.set() + release_new.set() + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +async def test_active_error_claim_can_be_cancelled_again( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(manager_module, "CANCEL_TASK_DRAIN_TIMEOUT_S", 0.01) + started = asyncio.Event() + first_cancellation = asyncio.Event() + second_cancellation = asyncio.Event() + release = asyncio.Event() + cancellation_count = 0 + + async def process(_claim: NodeClaim) -> None: + nonlocal cancellation_count + started.set() + while True: + try: + await release.wait() + return + except asyncio.CancelledError: + cancellation_count += 1 + if cancellation_count == 1: + first_cancellation.set() + continue + second_cancellation.set() + raise + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + await started.wait() + + try: + first = await manager.cancel_node( + _SCOPE, + "root", + reason=CancellationReason.STOP, + ) + await first_cancellation.wait() + assert first.effects[0].ui_owner is CancellationUiOwner.RUNNER + assert manager.task_count() == 1 + + second = await manager.cancel_node( + _SCOPE, + "root", + reason=CancellationReason.STOP, + ) + await second_cancellation.wait() + + assert [ + (effect.node.node_id, effect.ui_owner) for effect in second.effects + ] == [("root", CancellationUiOwner.RUNNER)] + assert cancellation_count == 2 + await _wait_for_no_tasks(manager) + finally: + release.set() + await _wait_for_no_tasks(manager) diff --git a/tests/messaging/test_tree_processor.py b/tests/messaging/test_tree_processor.py new file mode 100644 index 0000000..277688a --- /dev/null +++ b/tests/messaging/test_tree_processor.py @@ -0,0 +1,438 @@ +"""Manager-level task and cancellation ownership tests.""" + +import asyncio +import logging + +import pytest + +from free_claude_code.messaging.models import IncomingMessage, MessageScope +from free_claude_code.messaging.trees import ( + CancellationReason, + CancellationUiOwner, + FailureResult, + MessageState, + NodeClaim, + QueueEntry, + TreeQueueManager, +) +from free_claude_code.messaging.trees import manager as manager_module +from free_claude_code.messaging.trees import processor as processor_module +from free_claude_code.messaging.trees.node import MessageNode +from free_claude_code.messaging.trees.processor import TreeQueueProcessor +from free_claude_code.messaging.trees.runtime import MessageTree + +_SCOPE = MessageScope(platform="telegram", chat_id="chat") + + +def _incoming(node_id: str, *, reply_to: str | None = None) -> IncomingMessage: + return IncomingMessage( + text=f"prompt {node_id}", + chat_id=_SCOPE.chat_id, + user_id="user", + message_id=node_id, + platform=_SCOPE.platform, + reply_to_message_id=reply_to, + ) + + +async def _wait_for_no_tasks(manager: TreeQueueManager) -> None: + """Yield deterministic ready-queue checkpoints until task cleanup completes.""" + loop = asyncio.get_running_loop() + for _ in range(20): + if manager.task_count() == 0: + return + checkpoint = asyncio.Event() + loop.call_soon(checkpoint.set) + await checkpoint.wait() + assert manager.task_count() == 0 + + +@pytest.mark.asyncio +async def test_active_cancel_returns_runner_owned_effect_and_terminal_snapshot() -> ( + None +): + started = asyncio.Event() + + async def process(_claim: NodeClaim) -> None: + started.set() + await asyncio.Event().wait() + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + await started.wait() + + result = await manager.cancel_node( + _SCOPE, + "root", + reason=CancellationReason.STOP, + ) + + assert [(effect.node.node_id, effect.ui_owner) for effect in result.effects] == [ + ("root", CancellationUiOwner.RUNNER) + ] + assert len(result.snapshots) == 1 + assert result.snapshots[0].nodes["root"]["state"] == "error" + view = await manager.get_node(_SCOPE, "root") + assert view is not None and view.state is MessageState.ERROR + assert manager.task_count() == 0 + + +@pytest.mark.asyncio +async def test_queued_cancel_returns_workflow_effect_and_exact_queue_update() -> None: + release_root = asyncio.Event() + root_started = asyncio.Event() + child_started = asyncio.Event() + queue_updates: list[tuple[tuple[str, int], ...]] = [] + + async def process(claim: NodeClaim) -> None: + if claim.node.node_id == "root": + root_started.set() + await release_root.wait() + else: + child_started.set() + + async def capture_queue(queue: tuple[QueueEntry, ...]) -> None: + queue_updates.append( + tuple((entry.node.node_id, entry.position) for entry in queue) + ) + + manager = TreeQueueManager(process, queue_update_callback=capture_queue) + await manager.admit(_incoming("root"), "status-root") + await root_started.wait() + decision = await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + assert decision.position == 1 + + result = await manager.cancel_node( + _SCOPE, + "child", + reason=CancellationReason.STOP, + ) + + assert [(effect.node.node_id, effect.ui_owner) for effect in result.effects] == [ + ("child", CancellationUiOwner.WORKFLOW) + ] + assert queue_updates == [()] + assert result.snapshots[0].nodes["child"]["state"] == "error" + assert child_started.is_set() is False + + release_root.set() + await _wait_for_no_tasks(manager) + assert child_started.is_set() is False + + +@pytest.mark.asyncio +async def test_cancel_cleanup_timeout_is_bounded_and_task_remains_owned( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(manager_module, "CANCEL_TASK_DRAIN_TIMEOUT_S", 0.01) + started = asyncio.Event() + cancellation_seen = asyncio.Event() + release_cleanup = asyncio.Event() + + async def process(_claim: NodeClaim) -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancellation_seen.set() + await release_cleanup.wait() + raise + + manager = TreeQueueManager(process) + await manager.admit(_incoming("root"), "status-root") + await started.wait() + + try: + result = await asyncio.wait_for( + manager.cancel_node( + _SCOPE, + "root", + reason=CancellationReason.STOP, + ), + timeout=0.5, + ) + await cancellation_seen.wait() + assert result.effects[0].node.node_id == "root" + assert manager.task_count() == 1 + finally: + release_cleanup.set() + + await _wait_for_no_tasks(manager) + + +@pytest.mark.asyncio +async def test_escaped_processor_failure_persists_effects_through_manager_owner() -> ( + None +): + started = asyncio.Event() + release = asyncio.Event() + failures: list[FailureResult] = [] + queue_updates: list[tuple[QueueEntry, ...]] = [] + + async def process(claim: NodeClaim) -> None: + if claim.node.node_id == "root": + started.set() + await release.wait() + raise RuntimeError("processor boundary failed") + + async def capture_queue(queue: tuple[QueueEntry, ...]) -> None: + queue_updates.append(queue) + + manager = TreeQueueManager( + process, + queue_update_callback=capture_queue, + unexpected_failure_callback=failures.append, + ) + await manager.admit(_incoming("root"), "status-root") + await started.wait() + await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + + release.set() + await _wait_for_no_tasks(manager) + + assert len(failures) == 1 + failure = failures[0] + assert failure.snapshot is not None + assert {target.node_id for target in failure.affected} == {"root", "child"} + assert failure.snapshot.nodes["root"]["state"] == "error" + assert failure.snapshot.nodes["child"]["state"] == "error" + assert queue_updates == [()] + + +@pytest.mark.asyncio +async def test_wait_idle_spans_successor_publication_and_completion() -> None: + root_started = asyncio.Event() + release_root = asyncio.Event() + child_started = asyncio.Event() + release_child = asyncio.Event() + + async def process(claim: NodeClaim) -> None: + if claim.node.node_id == "root": + root_started.set() + await release_root.wait() + return + child_started.set() + await release_child.wait() + + manager = TreeQueueManager(process) + await asyncio.wait_for(manager.wait_idle(), timeout=0.1) + await manager.admit(_incoming("root"), "status-root") + await asyncio.wait_for(root_started.wait(), timeout=1) + await manager.admit( + _incoming("child", reply_to="root"), + "status-child", + parent_reference_id="root", + ) + idle_task = asyncio.create_task(manager.wait_idle()) + + try: + await asyncio.sleep(0) + assert not idle_task.done() + + release_root.set() + await asyncio.wait_for(child_started.wait(), timeout=1) + assert not idle_task.done() + + release_child.set() + await asyncio.wait_for(idle_task, timeout=1) + assert manager.task_count() == 0 + finally: + release_root.set() + release_child.set() + if not idle_task.done(): + idle_task.cancel() + with pytest.raises(asyncio.CancelledError): + await idle_task + + +@pytest.mark.asyncio +async def test_wait_idle_spans_pre_run_cancellation_recovery() -> None: + finish_started = asyncio.Event() + release_finish = asyncio.Event() + + async def process(_claim: NodeClaim) -> None: + raise AssertionError("pre-run cancellation must not enter the processor") + + async def fail_claim(_claim: NodeClaim) -> None: + raise AssertionError("cancellation must not fail the claim") + + async def finish_claim(_tree: MessageTree, _claim: NodeClaim) -> None: + finish_started.set() + await release_finish.wait() + + tree = MessageTree( + MessageNode( + node_id="root", + scope=_SCOPE, + prompt="prompt root", + status_message_id="status-root", + ) + ) + decision = await tree.enqueue_or_claim("root") + assert decision.claim is not None + processor = TreeQueueProcessor( + process, + claim_failure_callback=fail_claim, + claim_finished_callback=finish_claim, + ) + processor.launch(tree, decision.claim) + cancelled = processor.cancel(decision.claim, CancellationReason.STOP) + assert cancelled is not None + assert cancelled.runner_started is False + idle_task = asyncio.create_task(processor.wait_idle()) + + try: + await asyncio.wait_for(finish_started.wait(), timeout=1) + assert not idle_task.done() + + release_finish.set() + await asyncio.wait_for(cancelled.task, timeout=1) + await asyncio.wait_for(idle_task, timeout=1) + assert processor.task_count() == 0 + finally: + release_finish.set() + if not idle_task.done(): + idle_task.cancel() + with pytest.raises(asyncio.CancelledError): + await idle_task + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("log_messaging_error_details", "secret_is_logged"), + [(False, False), (True, True)], +) +async def test_wait_idle_surfaces_finish_failure_without_leaking_task_owner( + caplog: pytest.LogCaptureFixture, + log_messaging_error_details: bool, + secret_is_logged: bool, +) -> None: + secret = "unique-finish-callback-secret" + finish_error = RuntimeError(secret) + finish_calls = 0 + + async def process(_claim: NodeClaim) -> None: + return + + async def fail_claim(_claim: NodeClaim) -> None: + raise AssertionError("successful processing must not fail the claim") + + async def finish_claim(_tree: MessageTree, _claim: NodeClaim) -> None: + nonlocal finish_calls + finish_calls += 1 + raise finish_error + + tree = MessageTree( + MessageNode( + node_id="root", + scope=_SCOPE, + prompt="prompt root", + status_message_id="status-root", + ) + ) + decision = await tree.enqueue_or_claim("root") + assert decision.claim is not None + processor = TreeQueueProcessor( + process, + claim_failure_callback=fail_claim, + claim_finished_callback=finish_claim, + log_messaging_error_details=log_messaging_error_details, + ) + + with caplog.at_level(logging.ERROR): + processor.launch(tree, decision.claim) + with pytest.raises(RuntimeError) as raised: + await asyncio.wait_for(processor.wait_idle(), timeout=1) + + assert raised.value is finish_error + assert finish_calls == 1 + assert processor.task_count() == 0 + await asyncio.wait_for(processor.wait_idle(), timeout=0.1) + + messages = "\n".join(record.getMessage() for record in caplog.records) + assert "Claim completion callback failed for node root" in messages + assert "RuntimeError" in messages + assert (secret in messages) is secret_is_logged + + +@pytest.mark.asyncio +async def test_failed_launch_rolls_idle_state_back( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def process(_claim: NodeClaim) -> None: + return + + async def fail_claim(_claim: NodeClaim) -> None: + return + + async def finish_claim(_tree: MessageTree, _claim: NodeClaim) -> None: + return + + tree = MessageTree( + MessageNode( + node_id="root", + scope=_SCOPE, + prompt="prompt root", + status_message_id="status-root", + ) + ) + decision = await tree.enqueue_or_claim("root") + assert decision.claim is not None + processor = TreeQueueProcessor( + process, + claim_failure_callback=fail_claim, + claim_finished_callback=finish_claim, + ) + + def fail_create_task(*_args: object, **_kwargs: object) -> None: + raise RuntimeError("launch failed") + + monkeypatch.setattr( + processor_module.asyncio, + "create_task", + fail_create_task, + ) + + with pytest.raises(RuntimeError, match="launch failed"): + processor.launch(tree, decision.claim) + + assert processor.task_count() == 0 + await asyncio.wait_for(processor.wait_idle(), timeout=0.1) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("log_messaging_error_details", "secret_is_logged"), + [(False, False), (True, True)], +) +async def test_processor_failure_logging_respects_diagnostic_policy( + caplog: pytest.LogCaptureFixture, + log_messaging_error_details: bool, + secret_is_logged: bool, +) -> None: + secret = "unique-processor-exception-secret" + + async def process(_claim: NodeClaim) -> None: + raise RuntimeError(secret) + + manager = TreeQueueManager( + process, + log_messaging_error_details=log_messaging_error_details, + ) + with caplog.at_level(logging.ERROR): + await manager.admit(_incoming("root"), "status-root") + await asyncio.wait_for(manager.wait_idle(), timeout=1) + + messages = "\n".join(record.getMessage() for record in caplog.records) + + assert "Error processing node root" in messages + assert "RuntimeError" in messages + assert (secret in messages) is secret_is_logged diff --git a/tests/messaging/test_tree_queue.py b/tests/messaging/test_tree_queue.py new file mode 100644 index 0000000..1169298 --- /dev/null +++ b/tests/messaging/test_tree_queue.py @@ -0,0 +1,187 @@ +"""Focused tests for the tree package's private graph and queue values.""" + +import pytest + +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.trees.graph import MessageTreeGraph +from free_claude_code.messaging.trees.node import MessageNode, MessageState +from free_claude_code.messaging.trees.queue import MessageNodeQueue +from free_claude_code.messaging.trees.snapshot import ( + TreeSnapshot, + node_from_snapshot, + node_to_snapshot, +) + +_SCOPE = MessageScope(platform="telegram", chat_id="chat") + + +def _root() -> MessageNode: + return MessageNode( + node_id="root", + scope=_SCOPE, + prompt="prompt root", + status_message_id="status-root", + ) + + +def _add( + graph: MessageTreeGraph, + node_id: str, + status_message_id: str, + parent_id: str, +) -> MessageNode: + return graph.add_node( + node_id=node_id, + scope=_SCOPE, + prompt=f"prompt {node_id}", + status_message_id=status_message_id, + parent_id=parent_id, + parent_reference_id=parent_id, + ) + + +def test_node_snapshot_round_trip_preserves_execution_state_only() -> None: + node = _root() + node.children_ids.extend(["child-a", "child-b"]) + node.update_state(MessageState.ERROR) + node.update_state(MessageState.COMPLETED, session_id="session-root") + + snapshot = node_to_snapshot(node) + restored = node_from_snapshot(snapshot, _SCOPE) + + assert restored.node_id == "root" + assert restored.scope == _SCOPE + assert restored.prompt == "" + assert restored.status_message_id == "status-root" + assert restored.state is MessageState.COMPLETED + assert restored.session_id == "session-root" + assert restored.parent_reference_id is None + assert restored.children_ids == [] + assert "children_ids" not in snapshot + assert "created_at" not in snapshot + assert "completed_at" not in snapshot + assert "error_message" not in snapshot + + +def test_graph_snapshot_round_trip_preserves_links_and_status_lookup() -> None: + graph = MessageTreeGraph(_root()) + child = _add(graph, "child", "status-child", "root") + _add(graph, "grandchild", "status-grandchild", "child") + child.update_state(MessageState.COMPLETED, session_id="session-child") + + restored = MessageTreeGraph.from_snapshot(graph.snapshot()) + parent = restored.get_parent("grandchild") + status_child = restored.find_node_by_status_message("status-child") + + assert restored.root_id == "root" + assert parent is not None + assert parent.node_id == "child" + assert restored.get_parent_session_id("grandchild") == "session-child" + assert status_child is not None + assert status_child.node_id == "child" + assert restored.get_descendants("root") == ["root", "child", "grandchild"] + + +def test_graph_restore_normalizes_numeric_ids_to_string_references() -> None: + graph = MessageTreeGraph(_root()) + _add(graph, "child", "status-child", "root") + snapshot = graph.snapshot() + snapshot.nodes["child"]["node_id"] = 2 + snapshot.nodes["child"]["status_message_id"] = 123 + snapshot.nodes["child"]["parent_id"] = "root" + snapshot.nodes["2"] = snapshot.nodes.pop("child") + + restored = MessageTreeGraph.from_snapshot(snapshot) + + child = restored.find_node_by_status_message("123") + assert child is not None and child.node_id == "2" + + +def test_graph_restore_normalizes_legacy_parent_to_prompt_reference() -> None: + graph = MessageTreeGraph(_root()) + _add(graph, "child", "status-child", "root") + snapshot = graph.snapshot() + snapshot.nodes["child"].pop("parent_reference_id") + + restored = MessageTreeGraph.from_snapshot(snapshot) + + child = restored.get_node("child") + assert child is not None + assert child.parent_reference_id == "root" + assert restored.snapshot().nodes["child"]["parent_reference_id"] == "root" + + +def test_cleared_status_round_trip_preserves_prompt_anchor() -> None: + graph = MessageTreeGraph(_root()) + graph.clear_status("root") + + restored = MessageTreeGraph.from_snapshot(graph.snapshot()) + + root = restored.get_root() + assert root.status_message_id is None + assert root.state is MessageState.ERROR + assert restored.resolve_reference("root") is not None + assert restored.resolve_reference("status-root") is None + + +def test_snapshot_rejects_runnable_node_without_status() -> None: + snapshot = MessageTreeGraph(_root()).snapshot() + snapshot.nodes["root"]["status_message_id"] = None + + with pytest.raises(ValueError, match="requires a status"): + MessageTreeGraph.from_snapshot(snapshot) + + +def test_graph_rejects_duplicate_node_and_status_identity() -> None: + graph = MessageTreeGraph(_root()) + _add(graph, "child", "status-child", "root") + + with pytest.raises(ValueError, match="already exists"): + _add(graph, "child", "status-other", "root") + with pytest.raises(ValueError, match="already exists"): + _add(graph, "other", "status-child", "root") + with pytest.raises(ValueError, match="must be distinct"): + _add(graph, "same", "same", "root") + + +def test_graph_reference_subtree_can_remove_exact_nodes_and_status_lookups() -> None: + graph = MessageTreeGraph(_root()) + _add(graph, "branch", "status-branch", "root") + _add(graph, "leaf", "status-leaf", "branch") + _add(graph, "sibling", "status-sibling", "root") + + references = graph.get_reference_descendants("branch") + graph.remove_nodes( + {reference for reference in references if not reference.startswith("status-")} + ) + + assert graph.get_node("branch") is None + assert graph.get_node("leaf") is None + assert graph.find_node_by_status_message("status-branch") is None + assert graph.find_node_by_status_message("status-leaf") is None + assert graph.get_descendants("root") == ["root", "sibling"] + + +def test_tree_snapshot_rejects_invalid_wire_shapes() -> None: + assert TreeSnapshot.from_json(None) is None + assert TreeSnapshot.from_json({"root_id": "root", "nodes": []}) is None + assert TreeSnapshot.from_json({"nodes": {}}) is None + + +def test_node_queue_is_unique_fifo_and_supports_atomic_removal() -> None: + queue = MessageNodeQueue() + + assert queue.put("a") is True + assert queue.put("b") is True + assert queue.put("a") is False + assert queue.items() == ("a", "b") + assert queue.remove("a") is True + assert queue.remove("a") is False + assert queue.items() == ("b",) + assert queue.pop() == "b" + assert queue.pop() is None + + assert queue.put("c") is True + assert queue.put("d") is True + assert queue.drain() == ("c", "d") + assert queue.items() == () diff --git a/tests/messaging/test_tree_repository.py b/tests/messaging/test_tree_repository.py new file mode 100644 index 0000000..18e817c --- /dev/null +++ b/tests/messaging/test_tree_repository.py @@ -0,0 +1,194 @@ +"""Persistence and restored-manager contracts for messaging trees.""" + +import asyncio + +import pytest + +from free_claude_code.messaging.models import IncomingMessage, MessageScope +from free_claude_code.messaging.trees import ( + ConversationSnapshot, + MessageState, + NodeClaim, + TreeIdentity, + TreeQueueManager, + TreeSnapshot, +) +from free_claude_code.messaging.trees.node import MessageNode +from free_claude_code.messaging.trees.snapshot import node_to_snapshot + +TELEGRAM_CHAT = MessageScope(platform="telegram", chat_id="chat") +ROOT_IDENTITY = TreeIdentity(scope=TELEGRAM_CHAT, root_id="root") + + +def _incoming(node_id: str, *, reply_to: str | None = None) -> IncomingMessage: + return IncomingMessage( + text=f"prompt {node_id}", + chat_id="chat", + user_id="user", + message_id=node_id, + platform="telegram", + reply_to_message_id=reply_to, + ) + + +async def _wait_for_no_tasks(manager: TreeQueueManager) -> None: + loop = asyncio.get_running_loop() + for _ in range(20): + if manager.task_count() == 0: + return + checkpoint = asyncio.Event() + loop.call_soon(checkpoint.set) + await checkpoint.wait() + assert manager.task_count() == 0 + + +def _interrupted_conversation() -> ConversationSnapshot: + root = MessageNode( + node_id="root", + scope=TELEGRAM_CHAT, + prompt="prompt root", + status_message_id="status-root", + state=MessageState.COMPLETED, + session_id="session-root", + children_ids=["pending", "failed"], + ) + pending = MessageNode( + node_id="pending", + scope=TELEGRAM_CHAT, + prompt="prompt pending", + status_message_id="status-pending", + parent_id="root", + state=MessageState.PENDING, + children_ids=["running"], + ) + running = MessageNode( + node_id="running", + scope=TELEGRAM_CHAT, + prompt="prompt running", + status_message_id="status-running", + parent_id="pending", + state=MessageState.IN_PROGRESS, + ) + failed = MessageNode( + node_id="failed", + scope=TELEGRAM_CHAT, + prompt="prompt failed", + status_message_id="status-failed", + parent_id="root", + state=MessageState.ERROR, + ) + snapshot = TreeSnapshot( + scope=TELEGRAM_CHAT, + root_id="root", + nodes={ + node.node_id: node_to_snapshot(node) + for node in (root, pending, running, failed) + }, + ) + return ConversationSnapshot(trees={snapshot.identity: snapshot}) + + +@pytest.mark.asyncio +async def test_restore_reconciles_interrupted_nodes_before_manager_exposure() -> None: + processed: list[str] = [] + + async def process(claim: NodeClaim) -> None: + processed.append(claim.node.node_id) + + manager = TreeQueueManager.from_snapshot(_interrupted_conversation(), process) + + assert len(manager.restored_stale_targets) == 2 + assert manager.restored_snapshot is not None + assert manager.restored_snapshot == await manager.snapshot() + assert manager.task_count() == 0 + assert processed == [] + + root = await manager.get_node(TELEGRAM_CHAT, "root") + pending = await manager.get_node(TELEGRAM_CHAT, "pending") + running = await manager.get_node(TELEGRAM_CHAT, "status-running") + failed = await manager.get_node(TELEGRAM_CHAT, "failed") + assert root is not None and root.state is MessageState.COMPLETED + assert root.session_id == "session-root" + assert pending is not None and pending.state is MessageState.ERROR + assert running is not None and running.state is MessageState.ERROR + assert failed is not None and failed.state is MessageState.ERROR + assert await manager.resolve_node_id(TELEGRAM_CHAT, "status-pending") == "pending" + assert { + (target.scope, target.node_id) for target in manager.restored_stale_targets + } == { + (TELEGRAM_CHAT, "pending"), + (TELEGRAM_CHAT, "running"), + } + + normalized = await manager.snapshot() + normalized_tree = normalized.get_tree(ROOT_IDENTITY) + assert normalized_tree is not None + assert normalized_tree.nodes["pending"]["state"] == "error" + assert normalized_tree.nodes["running"]["state"] == "error" + assert normalized_tree.nodes["failed"]["state"] == "error" + assert all("error_message" not in node for node in normalized_tree.nodes.values()) + + +@pytest.mark.parametrize("corruption", ["cross_scope", "duplicate_reference"]) +def test_restore_rejects_tree_that_violates_scoped_reference_invariants( + corruption: str, +) -> None: + snapshot = _interrupted_conversation() + tree = snapshot.get_tree(ROOT_IDENTITY) + assert tree is not None + if corruption == "cross_scope": + tree.nodes["pending"]["incoming"] = { + "platform": "telegram", + "chat_id": "other-chat", + } + else: + tree.nodes["failed"]["status_message_id"] = "status-pending" + + manager = TreeQueueManager.from_snapshot(snapshot, lambda _claim: asyncio.sleep(0)) + + assert manager.get_tree_count() == 0 + assert manager.restored_snapshot == ConversationSnapshot() + + +@pytest.mark.asyncio +async def test_claim_state_and_session_updates_produce_detached_snapshots() -> None: + release = asyncio.Event() + started = asyncio.Event() + + async def process(_claim: NodeClaim) -> None: + started.set() + await release.wait() + + manager = TreeQueueManager(process) + decision = await manager.admit(_incoming("root"), "status-root") + assert decision.claim is not None + await started.wait() + + session_snapshot = await manager.record_session(decision.claim, "session-root") + completed_snapshot = await manager.complete_claim( + decision.claim, + "session-root", + ) + + assert session_snapshot is not None + assert session_snapshot.nodes["root"]["state"] == "in_progress" + assert session_snapshot.nodes["root"]["session_id"] == "session-root" + assert completed_snapshot is not None + assert completed_snapshot.nodes["root"]["state"] == "completed" + assert completed_snapshot.nodes["root"]["session_id"] == "session-root" + + assert decision.snapshot is not None + decision.snapshot.nodes["root"]["state"] = "mutated-copy" + view = await manager.get_node(TELEGRAM_CHAT, "status-root") + assert view is not None and view.state is MessageState.COMPLETED + assert view.session_id == "session-root" + assert await manager.get_message_ids_for_chat("telegram", "chat") == { + "root", + "status-root", + } + + release.set() + await _wait_for_no_tasks(manager) + assert await manager.snapshot() == ConversationSnapshot( + trees={ROOT_IDENTITY: completed_snapshot} + ) diff --git a/tests/messaging/test_voice_handlers.py b/tests/messaging/test_voice_handlers.py new file mode 100644 index 0000000..5275a7d --- /dev/null +++ b/tests/messaging/test_voice_handlers.py @@ -0,0 +1,205 @@ +"""Tests for voice note handling in Telegram and Discord platforms.""" + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.messaging.platforms.discord import ( + DISCORD_AVAILABLE, + DiscordRuntime, +) +from free_claude_code.messaging.platforms.discord_inbound import get_audio_attachment +from free_claude_code.messaging.platforms.telegram import TelegramRuntime + + +@pytest.fixture +def telegram_platform(): + transcriber = MagicMock() + transcriber.transcribe = AsyncMock(return_value="Hello from voice") + transcriber.close = AsyncMock() + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + platform = TelegramRuntime( + bot_token="test_token", + allowed_user_id="12345", + limiter=MagicMock(), + transcriber=transcriber, + ) + return platform, transcriber + + +@pytest.mark.asyncio +async def test_telegram_voice_disabled_sends_reply(): + """When voice_note_enabled is False, reply with disabled message.""" + with patch( + "free_claude_code.messaging.platforms.telegram.TELEGRAM_AVAILABLE", True + ): + telegram_platform = TelegramRuntime( + bot_token="test_token", + allowed_user_id="12345", + limiter=MagicMock(), + transcriber=None, + ) + mock_update = MagicMock() + mock_update.message.voice = MagicMock(file_id="f1", mime_type="audio/ogg") + mock_update.effective_user.id = 12345 + mock_update.effective_chat.id = 6789 + mock_update.message.reply_text = AsyncMock() + + await telegram_platform._on_telegram_voice(mock_update, MagicMock()) + + mock_update.message.reply_text.assert_called_once_with("Voice notes are disabled.") + + +@pytest.mark.asyncio +async def test_telegram_voice_unauthorized_ignored(telegram_platform): + """Voice from unauthorized user is ignored (no reply).""" + platform, transcriber = telegram_platform + mock_update = MagicMock() + mock_update.message.voice = MagicMock(file_id="f1", mime_type="audio/ogg") + mock_update.effective_user.id = 99999 # Not 12345 + mock_update.message.reply_text = AsyncMock() + + await platform._on_telegram_voice(mock_update, MagicMock()) + + mock_update.message.reply_text.assert_not_called() + transcriber.transcribe.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_telegram_voice_success_invokes_handler(telegram_platform): + """Successful transcription invokes message handler with transcribed text.""" + platform, transcriber = telegram_platform + handler = AsyncMock() + platform.on_message(handler) + + mock_update = MagicMock() + mock_voice = MagicMock(file_id="f1", mime_type="audio/ogg") + mock_update.message.voice = mock_voice + mock_update.message.message_id = 42 + mock_update.message.reply_to_message = None + mock_update.effective_user.id = 12345 + mock_update.effective_chat.id = 6789 + mock_update.message.reply_text = AsyncMock() + + mock_file = AsyncMock() + mock_context = MagicMock() + mock_context.bot.get_file = AsyncMock(return_value=mock_file) + + async def fake_download(custom_path=None): + if custom_path: + Path(custom_path).write_bytes(b"fake ogg") + + mock_file.download_to_drive = fake_download + + mock_queue_send = AsyncMock(return_value="999") + with patch.object( + platform.outbound, + "queue_send_message", + mock_queue_send, + ): + await platform._on_telegram_voice(mock_update, mock_context) + + mock_queue_send.assert_called_once() + call_args, call_kw = mock_queue_send.call_args + assert "Transcribing voice note" in call_args[1] + assert call_kw["reply_to"] == "42" + assert call_kw["fire_and_forget"] is False + transcriber.transcribe.assert_awaited_once() + + handler.assert_called_once() + incoming = handler.call_args[0][0] + assert incoming.text == "Hello from voice" + assert incoming.chat_id == "6789" + assert incoming.platform == "telegram" + assert incoming.status_message_id == "999" + + +@pytest.mark.asyncio +async def test_telegram_bulk_voice_cancellation_delegates(telegram_platform) -> None: + platform, _transcriber = telegram_platform + platform._voice_flow.cancel_all_pending_voices = AsyncMock(return_value=()) + + assert await platform.cancel_all_pending_voices() == () + + platform._voice_flow.cancel_all_pending_voices.assert_awaited_once() + + +@pytest.mark.skipif(not DISCORD_AVAILABLE, reason="discord.py not installed") +class TestDiscordGetAudioAttachment: + """Tests for _get_audio_attachment helper.""" + + def test_returns_none_when_no_attachments(self): + msg = MagicMock() + msg.attachments = [] + assert get_audio_attachment(msg) is None + + def test_returns_none_when_no_audio_attachments(self): + msg = MagicMock() + att = MagicMock() + att.content_type = "image/png" + att.filename = "pic.png" + msg.attachments = [att] + assert get_audio_attachment(msg) is None + + def test_returns_attachment_by_content_type(self): + msg = MagicMock() + att = MagicMock() + att.content_type = "audio/ogg" + att.filename = "voice.ogg" + msg.attachments = [att] + assert get_audio_attachment(msg) is att + + def test_returns_attachment_by_extension(self): + msg = MagicMock() + att = MagicMock() + att.content_type = "application/octet-stream" + att.filename = "voice.ogg" + msg.attachments = [att] + assert get_audio_attachment(msg) is att + + +@pytest.mark.skipif(not DISCORD_AVAILABLE, reason="discord.py not installed") +@pytest.mark.asyncio +async def test_discord_voice_disabled_sends_reply(): + """When voice_note_enabled is False, reply with disabled message.""" + platform = DiscordRuntime( + bot_token="token", + allowed_channel_ids="123", + limiter=MagicMock(), + transcriber=None, + ) + platform._message_handler = None + + mock_message = MagicMock() + mock_message.author.bot = False + mock_message.content = None + mock_message.channel.id = 123 + mock_message.reply = AsyncMock() + + mock_att = MagicMock() + mock_att.content_type = "audio/ogg" + mock_att.filename = "voice.ogg" + mock_message.attachments = [mock_att] + + await platform._on_discord_message(mock_message) + + mock_message.reply.assert_called_once_with("Voice notes are disabled.") + + +@pytest.mark.skipif(not DISCORD_AVAILABLE, reason="discord.py not installed") +@pytest.mark.asyncio +async def test_discord_bulk_voice_cancellation_delegates() -> None: + platform = DiscordRuntime( + bot_token="token", + allowed_channel_ids="123", + limiter=MagicMock(), + transcriber=None, + ) + platform._voice_flow.cancel_all_pending_voices = AsyncMock(return_value=()) + + assert await platform.cancel_all_pending_voices() == () + + platform._voice_flow.cancel_all_pending_voices.assert_awaited_once() diff --git a/tests/messaging/test_voice_services.py b/tests/messaging/test_voice_services.py new file mode 100644 index 0000000..f836358 --- /dev/null +++ b/tests/messaging/test_voice_services.py @@ -0,0 +1,653 @@ +import asyncio + +import pytest + +from free_claude_code.messaging.models import MessageScope +from free_claude_code.messaging.voice import ( + PendingVoiceClaim, + PendingVoiceRegistry, + VoiceCancellationResult, + VoiceHandoffOutcome, +) + +TELEGRAM_CHAT = MessageScope(platform="telegram", chat_id="chat") +DISCORD_CHAT = MessageScope(platform="discord", chat_id="chat") + + +class AsyncNoop: + async def __call__(self) -> None: + return None + + +class FatalVoiceFailure(BaseException): + pass + + +async def _reserve_bound( + registry: PendingVoiceRegistry, + *, + scope: MessageScope = TELEGRAM_CHAT, + voice_id: str = "voice-1", + status_id: str = "status-1", +) -> PendingVoiceClaim: + claim = await registry.reserve(scope, voice_id) + assert claim is not None + assert await registry.bind_status(claim, status_id) is True + return claim + + +@pytest.mark.asyncio +async def test_cancel_before_status_binding_rejects_late_flow() -> None: + registry = PendingVoiceRegistry() + claim = await registry.reserve(TELEGRAM_CHAT, "voice-1") + callback_called = False + + async def callback() -> None: + nonlocal callback_called + callback_called = True + + assert claim is not None + cancellation = await registry.cancel(TELEGRAM_CHAT, "voice-1") + assert cancellation is not None + assert cancellation == VoiceCancellationResult( + scope=TELEGRAM_CHAT, + voice_message_id="voice-1", + status_message_id=None, + delete_message_ids=frozenset({"voice-1"}), + ) + assert cancellation.delete_message_ids == frozenset({"voice-1"}) + assert await registry.bind_status(claim, "status-1") is False + assert await registry.handoff(claim, callback) is VoiceHandoffOutcome.REJECTED + assert callback_called is False + + +@pytest.mark.asyncio +async def test_handoff_remains_addressable_until_callback_completes() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + release = asyncio.Event() + + async def callback() -> None: + started.set() + await release.wait() + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + + assert await registry.reserve(TELEGRAM_CHAT, "voice-1") is None + release.set() + assert await asyncio.wait_for(handoff_task, timeout=1) is ( + VoiceHandoffOutcome.COMPLETED + ) + assert await registry.cancel(TELEGRAM_CHAT, "status-1") is None + + +@pytest.mark.asyncio +async def test_cancel_removes_then_drains_handoff_without_holding_lock() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + cancellation_received = asyncio.Event() + release = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancellation_received.set() + await release.wait() + raise + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + cancel_task = asyncio.create_task(registry.cancel(TELEGRAM_CHAT, "status-1")) + await asyncio.wait_for(cancellation_received.wait(), timeout=1) + + assert not cancel_task.done() + replacement = await asyncio.wait_for( + registry.reserve(TELEGRAM_CHAT, "voice-1"), timeout=1 + ) + assert replacement is not None + assert await registry.bind_status(replacement, "status-new") is True + + release.set() + cancellation = await asyncio.wait_for(cancel_task, timeout=1) + assert cancellation is not None + assert cancellation == VoiceCancellationResult( + scope=TELEGRAM_CHAT, + voice_message_id="voice-1", + status_message_id="status-1", + delete_message_ids=frozenset({"status-1"}), + ) + assert cancellation.delete_message_ids == frozenset({"status-1"}) + assert await asyncio.wait_for(handoff_task, timeout=1) is ( + VoiceHandoffOutcome.CANCELLED + ) + assert await registry.cancel(TELEGRAM_CHAT, "status-new") is not None + + +@pytest.mark.asyncio +async def test_voice_reference_authorizes_voice_and_status_deletion() -> None: + registry = PendingVoiceRegistry() + await _reserve_bound(registry) + + cancellation = await registry.cancel(TELEGRAM_CHAT, "voice-1") + + assert cancellation is not None + assert cancellation.delete_message_ids == frozenset({"voice-1", "status-1"}) + + +@pytest.mark.asyncio +async def test_cancel_scope_preserves_other_platform_chats() -> None: + registry = PendingVoiceRegistry() + await _reserve_bound(registry) + await _reserve_bound( + registry, + scope=DISCORD_CHAT, + voice_id="voice-2", + status_id="status-2", + ) + + cancellations = await registry.cancel_scope(TELEGRAM_CHAT) + + assert len(cancellations) == 1 + assert cancellations[0].delete_message_ids == frozenset({"voice-1", "status-1"}) + assert await registry.cancel(DISCORD_CHAT, "status-2") is not None + + +@pytest.mark.asyncio +async def test_handoff_propagates_callback_error_when_completion_wins() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + + async def callback() -> None: + raise RuntimeError("handler failed") + + with pytest.raises(RuntimeError, match="handler failed"): + await registry.handoff(claim, callback) + + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + + +@pytest.mark.asyncio +async def test_handoff_suppresses_late_callback_error_when_cancellation_wins() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + raise RuntimeError("late handler failure") from None + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is not None + assert await asyncio.wait_for(handoff_task, timeout=1) is ( + VoiceHandoffOutcome.CANCELLED + ) + + +@pytest.mark.asyncio +async def test_handoff_caller_cancellation_drains_child_and_propagates() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + cancellation_received = asyncio.Event() + release = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancellation_received.set() + await release.wait() + raise + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + handoff_task.cancel() + await asyncio.wait_for(cancellation_received.wait(), timeout=1) + assert handoff_task.cancelling() == 1 + assert await registry.reserve(TELEGRAM_CHAT, "voice-1") is None + assert await registry.reserve(TELEGRAM_CHAT, "status-1") is None + + handoff_task.cancel() + await asyncio.sleep(0) + assert handoff_task.cancelling() == 2 + handoff_task.cancel() + await asyncio.sleep(0) + assert handoff_task.cancelling() == 3 + assert not handoff_task.done() + release.set() + with pytest.raises(asyncio.CancelledError): + await handoff_task + assert handoff_task.cancelling() == 3 + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + + +@pytest.mark.asyncio +async def test_handoff_drains_child_when_cancellation_is_already_pending() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + finished = asyncio.Event() + release = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await release.wait() + finally: + finished.set() + + async def cancelled_caller() -> VoiceHandoffOutcome: + current = asyncio.current_task() + assert current is not None + current.cancel() + return await registry.handoff(claim, callback) + + handoff_task = asyncio.create_task(cancelled_caller()) + try: + with pytest.raises(asyncio.CancelledError): + await handoff_task + assert started.is_set() + assert finished.is_set() + assert handoff_task.cancelling() == 1 + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + finally: + release.set() + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_external_cancel_owns_aliases_during_caller_cancelled_drain() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + caller_cancellation_received = asyncio.Event() + external_cancellation_received = asyncio.Event() + release = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + caller_cancellation_received.set() + try: + await release.wait() + except asyncio.CancelledError: + external_cancellation_received.set() + await release.wait() + raise + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + + handoff_task.cancel() + await asyncio.wait_for(caller_cancellation_received.wait(), timeout=1) + cancel_task = asyncio.create_task(registry.cancel(TELEGRAM_CHAT, "status-1")) + await asyncio.wait_for(external_cancellation_received.wait(), timeout=1) + + replacement = await registry.reserve(TELEGRAM_CHAT, "voice-1") + assert replacement is not None + assert await registry.bind_status(replacement, "status-new") is True + release.set() + + cancellation = await asyncio.wait_for(cancel_task, timeout=1) + assert cancellation == VoiceCancellationResult( + scope=TELEGRAM_CHAT, + voice_message_id="voice-1", + status_message_id="status-1", + delete_message_ids=frozenset({"status-1"}), + ) + with pytest.raises(asyncio.CancelledError): + await handoff_task + assert await registry.cancel(TELEGRAM_CHAT, "status-new") is not None + + +@pytest.mark.asyncio +async def test_handoff_finalization_survives_repeated_caller_cancellation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + finalization_started = asyncio.Event() + release_finalization = asyncio.Event() + complete_if_owned = registry._complete_if_owned + + async def gated_completion(entry) -> bool: + finalization_started.set() + await release_finalization.wait() + return await complete_if_owned(entry) + + monkeypatch.setattr(registry, "_complete_if_owned", gated_completion) + handoff_task = asyncio.create_task(registry.handoff(claim, AsyncNoop())) + await asyncio.wait_for(finalization_started.wait(), timeout=1) + + handoff_task.cancel() + await asyncio.sleep(0) + handoff_task.cancel() + await asyncio.sleep(0) + assert not handoff_task.done() + + release_finalization.set() + with pytest.raises(asyncio.CancelledError): + await handoff_task + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + + +@pytest.mark.asyncio +async def test_cancel_drain_survives_repeated_caller_cancellation() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + child_cancelled = asyncio.Event() + release = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + child_cancelled.set() + await release.wait() + raise + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + cancel_task = asyncio.create_task(registry.cancel(TELEGRAM_CHAT, "voice-1")) + await asyncio.wait_for(child_cancelled.wait(), timeout=1) + + cancel_task.cancel() + await asyncio.sleep(0) + cancel_task.cancel() + await asyncio.sleep(0) + assert not cancel_task.done() + + release.set() + with pytest.raises(asyncio.CancelledError): + await cancel_task + assert await asyncio.wait_for(handoff_task, timeout=1) is ( + VoiceHandoffOutcome.CANCELLED + ) + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + + +@pytest.mark.asyncio +async def test_timeout_and_independent_cancellation_remain_distinct() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + child_cancelled = asyncio.Event() + release = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + child_cancelled.set() + await release.wait() + raise + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + + async def timed_cancel() -> VoiceCancellationResult | None: + async with asyncio.timeout(0.01): + return await registry.cancel(TELEGRAM_CHAT, "voice-1") + + cancel_task = asyncio.create_task(timed_cancel()) + await asyncio.wait_for(child_cancelled.wait(), timeout=1) + await asyncio.sleep(0.05) + assert cancel_task.cancelling() == 1 + cancel_task.cancel() + await asyncio.sleep(0) + assert cancel_task.cancelling() == 2 + release.set() + + with pytest.raises(asyncio.CancelledError): + await cancel_task + assert await asyncio.wait_for(handoff_task, timeout=1) is ( + VoiceHandoffOutcome.CANCELLED + ) + + +@pytest.mark.asyncio +async def test_reentrant_callback_cancellation_does_not_join_a_cycle() -> None: + registry = PendingVoiceRegistry() + first = await _reserve_bound( + registry, + voice_id="voice-1", + status_id="status-1", + ) + second = await _reserve_bound( + registry, + voice_id="voice-2", + status_id="status-2", + ) + second_started = asyncio.Event() + first_cancellation: VoiceCancellationResult | None = None + second_cancellation: VoiceCancellationResult | None = None + + async def first_callback() -> None: + nonlocal second_cancellation + await second_started.wait() + second_cancellation = await registry.cancel(TELEGRAM_CHAT, "voice-2") + + async def second_callback() -> None: + nonlocal first_cancellation + second_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + first_cancellation = await registry.cancel(TELEGRAM_CHAT, "voice-1") + raise + + first_handoff = asyncio.create_task(registry.handoff(first, first_callback)) + second_handoff = asyncio.create_task(registry.handoff(second, second_callback)) + + assert await asyncio.wait_for(first_handoff, timeout=1) is ( + VoiceHandoffOutcome.COMPLETED + ) + assert await asyncio.wait_for(second_handoff, timeout=1) is ( + VoiceHandoffOutcome.CANCELLED + ) + assert first_cancellation is None + assert second_cancellation is not None + assert second_cancellation.voice_message_id == "voice-2" + + +@pytest.mark.asyncio +async def test_nested_callback_task_cannot_cancel_its_own_claim() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + cancellation: VoiceCancellationResult | None = None + + async def nested_cancel() -> None: + nonlocal cancellation + cancellation = await registry.cancel(TELEGRAM_CHAT, "status-1") + + async def callback() -> None: + await asyncio.create_task(nested_cancel()) + + assert await asyncio.wait_for(registry.handoff(claim, callback), timeout=1) is ( + VoiceHandoffOutcome.COMPLETED + ) + assert cancellation is None + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + + +@pytest.mark.asyncio +async def test_nested_callback_task_is_excluded_from_bulk_cancellation() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + cancellations: tuple[VoiceCancellationResult, ...] | None = None + + async def nested_cancel_all() -> None: + nonlocal cancellations + cancellations = await registry.cancel_all() + + async def callback() -> None: + await asyncio.create_task(nested_cancel_all()) + + assert await asyncio.wait_for(registry.handoff(claim, callback), timeout=1) is ( + VoiceHandoffOutcome.COMPLETED + ) + assert cancellations == () + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + + +@pytest.mark.asyncio +async def test_fatal_callback_failure_releases_aliases_and_propagates() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + + async def callback() -> None: + raise FatalVoiceFailure + + with pytest.raises(FatalVoiceFailure): + await registry.handoff(claim, callback) + + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + assert await registry.cancel(TELEGRAM_CHAT, "status-1") is None + + +@pytest.mark.asyncio +async def test_fatal_failure_after_caller_cancellation_releases_aliases() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + cancellation_received = asyncio.Event() + release = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancellation_received.set() + await release.wait() + raise FatalVoiceFailure from None + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + handoff_task.cancel() + await asyncio.wait_for(cancellation_received.wait(), timeout=1) + + assert await registry.reserve(TELEGRAM_CHAT, "voice-1") is None + release.set() + with pytest.raises(FatalVoiceFailure): + await handoff_task + + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None + assert await registry.cancel(TELEGRAM_CHAT, "status-1") is None + + +@pytest.mark.asyncio +async def test_fatal_loser_failure_is_retrieved_and_propagated() -> None: + registry = PendingVoiceRegistry() + claim = await _reserve_bound(registry) + started = asyncio.Event() + + async def callback() -> None: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + raise FatalVoiceFailure from None + + handoff_task = asyncio.create_task(registry.handoff(claim, callback)) + await asyncio.wait_for(started.wait(), timeout=1) + + with pytest.raises(FatalVoiceFailure): + await registry.cancel(TELEGRAM_CHAT, "voice-1") + with pytest.raises(FatalVoiceFailure): + await handoff_task + + assert await registry.cancel(TELEGRAM_CHAT, "status-1") is None + + +@pytest.mark.asyncio +async def test_cancel_all_deduplicates_aliases_and_excludes_current_child() -> None: + registry = PendingVoiceRegistry() + first = await _reserve_bound(registry, voice_id="voice-1", status_id="status-1") + second = await _reserve_bound( + registry, + scope=DISCORD_CHAT, + voice_id="voice-2", + status_id="status-2", + ) + first_started = asyncio.Event() + first_cancelled = asyncio.Event() + cancellations: tuple[VoiceCancellationResult, ...] | None = None + + async def first_callback() -> None: + first_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + first_cancelled.set() + raise + + async def second_callback() -> None: + nonlocal cancellations + await first_started.wait() + cancellations = await registry.cancel_all() + + first_handoff = asyncio.create_task(registry.handoff(first, first_callback)) + second_handoff = asyncio.create_task(registry.handoff(second, second_callback)) + + assert await asyncio.wait_for(first_handoff, timeout=1) is ( + VoiceHandoffOutcome.CANCELLED + ) + assert await asyncio.wait_for(second_handoff, timeout=1) is ( + VoiceHandoffOutcome.COMPLETED + ) + assert first_cancelled.is_set() + assert cancellations is not None + assert len(cancellations) == 1 + assert {result.scope for result in cancellations} == {TELEGRAM_CHAT} + assert {result.delete_message_ids for result in cancellations} == { + frozenset({"voice-1", "status-1"}), + } + + +@pytest.mark.asyncio +async def test_stale_claim_cannot_mutate_reused_voice_id() -> None: + registry = PendingVoiceRegistry() + stale_claim = await registry.reserve(TELEGRAM_CHAT, "voice-1") + + assert stale_claim is not None + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is not None + + current_claim = await _reserve_bound( + registry, voice_id="voice-1", status_id="status-current" + ) + + assert current_claim != stale_claim + assert await registry.bind_status(stale_claim, "status-stale") is False + assert ( + await registry.handoff(stale_claim, AsyncNoop()) is VoiceHandoffOutcome.REJECTED + ) + assert await registry.discard(stale_claim) is False + assert await registry.cancel(TELEGRAM_CHAT, "status-current") is not None + + +@pytest.mark.asyncio +async def test_pending_voice_registry_rejects_duplicate_and_unbound_handoff() -> None: + registry = PendingVoiceRegistry() + claim = await registry.reserve(TELEGRAM_CHAT, "voice-1") + + assert claim is not None + assert await registry.reserve(TELEGRAM_CHAT, "voice-1") is None + assert await registry.handoff(claim, AsyncNoop()) is VoiceHandoffOutcome.REJECTED + assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is not None diff --git a/tests/provider_request_mocks.py b/tests/provider_request_mocks.py new file mode 100644 index 0000000..a8ae036 --- /dev/null +++ b/tests/provider_request_mocks.py @@ -0,0 +1,25 @@ +"""Shared MagicMock request objects for OpenAI-compatible provider tests.""" + +from unittest.mock import MagicMock + + +def make_openai_compat_stream_request( + *, model: str = "test-model", stream: bool = True +) -> MagicMock: + """Minimal request stub matching :meth:`OpenAIChatProvider._build_request_body` needs.""" + req = MagicMock() + req.model = model + req.stream = stream + req.messages = [] + req.system = None + req.tools = None + req.tool_choice = None + req.metadata = None + req.max_tokens = 4096 + req.temperature = None + req.top_p = None + req.top_k = None + req.stop_sequences = None + req.extra_body = None + req.thinking = None + return req diff --git a/tests/providers/request_factory.py b/tests/providers/request_factory.py new file mode 100644 index 0000000..bce4cff --- /dev/null +++ b/tests/providers/request_factory.py @@ -0,0 +1,25 @@ +"""Concrete Anthropic request factory for provider tests.""" + +from typing import Any + +from free_claude_code.core.anthropic.models import MessagesRequest + + +def make_messages_request( + model: str = "test-model", **overrides: Any +) -> MessagesRequest: + """Build a real Messages request with provider-test defaults.""" + data: dict[str, Any] = { + "model": model, + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 100, + "temperature": 0.5, + "top_p": 0.9, + "system": "System prompt", + "stop_sequences": None, + "tools": [], + "extra_body": {}, + "thinking": {"enabled": True}, + } + data.update(overrides) + return MessagesRequest.model_validate(data) diff --git a/tests/providers/support.py b/tests/providers/support.py new file mode 100644 index 0000000..1f3266c --- /dev/null +++ b/tests/providers/support.py @@ -0,0 +1,76 @@ +"""Provider test doubles with explicit limiter ownership.""" + +from collections.abc import Callable +from typing import Any + +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import ( + OpenAIChatProvider, + create_openai_chat_provider, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter + + +class PassthroughProviderRateLimiter(ProviderRateLimiter): + """Skip retry timing while retaining the real concurrency context manager.""" + + def __init__(self) -> None: + super().__init__( + rate_limit=1_000_000, + rate_window=1.0, + max_concurrency=1_000, + ) + + async def execute_with_retry( + self, + fn: Callable[..., Any], + *args: Any, + **kwargs: Any, + ) -> Any: + kwargs.pop("provider_failure_override", None) + return await fn(*args, **kwargs) + + +class ImmediateRetryProviderRateLimiter(ProviderRateLimiter): + """Run the real retry policy without exercising wall-clock backoff.""" + + async def execute_with_retry( + self, + fn: Callable[..., Any], + *args: Any, + **kwargs: Any, + ) -> Any: + kwargs.update(base_delay=0.0, max_delay=0.0, jitter=0.0) + return await super().execute_with_retry(fn, *args, **kwargs) + + def extend_reactive_block(self, seconds: float) -> None: + """Leave reactive timing to the limiter's dedicated unit tests.""" + del seconds + + +def passthrough_rate_limiter() -> ProviderRateLimiter: + """Return a fresh limiter test double for one provider instance.""" + return PassthroughProviderRateLimiter() + + +def profiled_provider( + provider_id: str, + config: ProviderConfig, + *, + rate_limiter: ProviderRateLimiter | None = None, +) -> OpenAIChatProvider: + """Construct one declarative provider for a focused behavior test.""" + return create_openai_chat_provider( + provider_id, + config, + rate_limiter or passthrough_rate_limiter(), + ) + + +def retrying_rate_limiter() -> ProviderRateLimiter: + """Return a limiter that exercises retry policy without elapsed time.""" + return ImmediateRetryProviderRateLimiter( + rate_limit=1_000_000, + rate_window=1.0, + max_concurrency=1_000, + ) diff --git a/tests/providers/test_cerebras.py b/tests/providers/test_cerebras.py new file mode 100644 index 0000000..d8306bd --- /dev/null +++ b/tests/providers/test_cerebras.py @@ -0,0 +1,197 @@ +"""Tests for Cerebras Inference (OpenAI-compatible) provider.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import CEREBRAS_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def make_request(**overrides): + return make_messages_request("llama3.1-8b", **overrides) + + +@pytest.fixture +def cerebras_config(): + return ProviderConfig( + api_key="test_cerebras_key", + base_url=CEREBRAS_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def cerebras_provider(cerebras_config): + return profiled_provider( + "cerebras", cerebras_config, rate_limiter=passthrough_rate_limiter() + ) + + +def test_init(cerebras_config): + """Test provider initialization.""" + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = profiled_provider( + "cerebras", cerebras_config, rate_limiter=passthrough_rate_limiter() + ) + assert provider._api_key == "test_cerebras_key" + assert provider._base_url == CEREBRAS_DEFAULT_BASE + mock_openai.assert_called_once() + + +def test_default_base_url_constant(): + assert CEREBRAS_DEFAULT_BASE == "https://api.cerebras.ai/v1" + + +def test_build_request_body_basic(cerebras_provider): + """Basic request body conversion attaches system message from Claude request.""" + req = make_request() + body = cerebras_provider._build_request_body(req) + + assert body["model"] == "llama3.1-8b" + assert body["messages"][0]["role"] == "system" + assert "max_completion_tokens" in body + + +def test_build_request_body_global_disable_blocks_reasoning_mapping(): + provider = profiled_provider( + "cerebras", + ProviderConfig( + api_key="test_cerebras_key", + base_url=CEREBRAS_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + body = provider._build_request_body(req) + + roles = [m.get("role") for m in body.get("messages", [])] + assert "assistant_reasoning_content" not in roles + + +def test_build_request_body_remaps_max_tokens_preserves_message_name(cerebras_provider): + """Cerebras does not strip message ``name``; ``max_tokens`` maps to completion field.""" + with patch( + "free_claude_code.providers.openai_chat.request_policy.build_base_request_body" + ) as mock_convert: + mock_convert.return_value = { + "model": "llama3.1-8b", + "messages": [{"role": "user", "name": "alice", "content": "hi"}], + "max_tokens": 42, + } + req = make_request() + body = cerebras_provider._build_request_body(req) + + assert body["messages"][0].get("name") == "alice" + assert body.get("max_tokens") is None + assert body["max_completion_tokens"] == 42 + + +def test_build_request_body_prefers_existing_max_completion_tokens(cerebras_provider): + with patch( + "free_claude_code.providers.openai_chat.request_policy.build_base_request_body" + ) as mock_convert: + mock_convert.return_value = { + "model": "llama3.1-8b", + "messages": [{"role": "user", "content": "x"}], + "max_completion_tokens": 77, + "max_tokens": 999, + } + body = cerebras_provider._build_request_body(make_request()) + + assert body["max_completion_tokens"] == 77 + assert "max_tokens" not in body + + +def test_build_request_body_preserves_caller_extra_body(cerebras_provider): + req = make_request(extra_body={"clear_thinking": False}) + + body = cerebras_provider._build_request_body(req) + + eb = body.get("extra_body") + assert isinstance(eb, dict) + assert eb.get("clear_thinking") is False + + +@pytest.mark.asyncio +async def test_stream_response_text(cerebras_provider): + """Text content deltas are emitted as text blocks.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello back!", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + cerebras_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in cerebras_provider.stream_response(req)] + + assert any( + '"text_delta"' in event and "Hello back!" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(cerebras_provider): + """reasoning_content deltas are emitted as thinking blocks.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking...", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + cerebras_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in cerebras_provider.stream_response(req)] + + assert any( + '"thinking_delta"' in event and "Thinking..." in event for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(cerebras_provider): + cerebras_provider._client = AsyncMock() + + await cerebras_provider.cleanup() + + cerebras_provider._client.close.assert_called_once() diff --git a/tests/providers/test_cloudflare.py b/tests/providers/test_cloudflare.py new file mode 100644 index 0000000..3d6411c --- /dev/null +++ b/tests/providers/test_cloudflare.py @@ -0,0 +1,312 @@ +"""Tests for Cloudflare Workers AI OpenAI-compatible chat provider.""" + +from collections.abc import AsyncIterator +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.config.provider_catalog import CLOUDFLARE_AI_REST_ROOT +from free_claude_code.core.anthropic.models import Message, MessagesRequest +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.cloudflare import ( + CloudflareProvider, + cloudflare_ai_base_url, +) +from tests.providers.support import passthrough_rate_limiter + +_ACCOUNT_ID = "account-123" +_BASE_URL = f"{CLOUDFLARE_AI_REST_ROOT}/accounts/{_ACCOUNT_ID}/ai/v1" +_MODEL_SEARCH_URL = f"{CLOUDFLARE_AI_REST_ROOT}/accounts/{_ACCOUNT_ID}/ai/models/search" + + +@pytest.fixture +def cloudflare_config() -> ProviderConfig: + return ProviderConfig( + api_key="test-cloudflare-token", + base_url=CLOUDFLARE_AI_REST_ROOT, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def cloudflare_provider(cloudflare_config: ProviderConfig) -> CloudflareProvider: + return CloudflareProvider( + cloudflare_config, + account_id=_ACCOUNT_ID, + rate_limiter=passthrough_rate_limiter(), + ) + + +def _request(model: str = "@cf/moonshotai/kimi-k2.6") -> MessagesRequest: + return MessagesRequest( + model=model, + max_tokens=100, + messages=[Message(role="user", content="hi")], + ) + + +def _chunk(delta: SimpleNamespace, *, finish_reason: str = "stop") -> SimpleNamespace: + return SimpleNamespace( + choices=[SimpleNamespace(delta=delta, finish_reason=finish_reason)], + usage=SimpleNamespace(completion_tokens=5, prompt_tokens=8), + ) + + +async def _stream(*chunks: SimpleNamespace) -> AsyncIterator[SimpleNamespace]: + for chunk in chunks: + yield chunk + + +def test_cloudflare_ai_base_url_uses_account_scoped_openai_chat_root() -> None: + assert cloudflare_ai_base_url(CLOUDFLARE_AI_REST_ROOT, "account/with slash") == ( + f"{CLOUDFLARE_AI_REST_ROOT}/accounts/account%2Fwith%20slash/ai/v1" + ) + + +def test_missing_account_id_raises_authentication_error( + cloudflare_config: ProviderConfig, +) -> None: + with pytest.raises(ApplicationUnavailableError, match="CLOUDFLARE_ACCOUNT_ID"): + CloudflareProvider( + cloudflare_config, account_id=" ", rate_limiter=passthrough_rate_limiter() + ) + + +def test_init_composes_account_scoped_openai_chat_base_url( + cloudflare_config: ProviderConfig, +) -> None: + with ( + patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai, + patch("httpx.AsyncClient") as mock_httpx_client, + ): + provider = CloudflareProvider( + cloudflare_config, + account_id=_ACCOUNT_ID, + rate_limiter=passthrough_rate_limiter(), + ) + + assert provider._api_key == "test-cloudflare-token" + assert provider._base_url == _BASE_URL + assert provider._model_search_url == _MODEL_SEARCH_URL + assert provider._provider_name == "CLOUDFLARE" + assert mock_openai.call_args.kwargs["base_url"] == _BASE_URL + assert mock_openai.call_args.kwargs["api_key"] == "test-cloudflare-token" + assert mock_httpx_client.called + + +def test_model_list_headers_use_bearer_auth( + cloudflare_provider: CloudflareProvider, +) -> None: + assert cloudflare_provider._model_list_headers() == { + "Authorization": "Bearer test-cloudflare-token" + } + + +def test_build_request_body_preserves_literal_cf_model_id_and_controls_thinking( + cloudflare_provider: CloudflareProvider, +) -> None: + request = MessagesRequest.model_validate( + { + "model": "@cf/moonshotai/kimi-k2.6", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 100, + "thinking": {"type": "enabled", "budget_tokens": 2048}, + } + ) + + body = cloudflare_provider._build_request_body(request, thinking_enabled=True) + + assert body["model"] == "@cf/moonshotai/kimi-k2.6" + assert body["max_completion_tokens"] == 100 + assert "max_tokens" not in body + assert body["extra_body"]["chat_template_kwargs"]["thinking"] is True + + +def test_build_request_body_disabled_thinking_sets_cloudflare_template_flag( + cloudflare_provider: CloudflareProvider, +) -> None: + request = MessagesRequest.model_validate( + { + "model": "@cf/moonshotai/kimi-k2.6", + "messages": [{"role": "user", "content": "Hello"}], + "thinking": {"type": "disabled"}, + } + ) + + body = cloudflare_provider._build_request_body(request, thinking_enabled=True) + + assert body["extra_body"]["chat_template_kwargs"]["thinking"] is False + + +def test_build_request_body_preserves_user_extra_body_without_overriding_thinking( + cloudflare_provider: CloudflareProvider, +) -> None: + request = MessagesRequest.model_validate( + { + "model": "@cf/moonshotai/kimi-k2.6", + "messages": [{"role": "user", "content": "Hello"}], + "extra_body": {"chat_template_kwargs": {"thinking": False}}, + } + ) + + body = cloudflare_provider._build_request_body(request, thinking_enabled=True) + + assert body["extra_body"]["chat_template_kwargs"]["thinking"] is False + + +@pytest.mark.asyncio +async def test_lists_models_from_cloudflare_model_search_endpoint( + cloudflare_provider: CloudflareProvider, +) -> None: + response = httpx.Response( + 200, + json={ + "object": "list", + "data": [ + {"id": "@cf/moonshotai/kimi-k2.6", "object": "model"}, + {"id": "@cf/meta/llama-4-scout-17b-16e-instruct", "object": "model"}, + ], + }, + request=httpx.Request("GET", _MODEL_SEARCH_URL), + ) + with patch.object( + cloudflare_provider._model_list_client, + "get", + new_callable=AsyncMock, + return_value=response, + ) as mock_get: + assert await cloudflare_provider.list_model_ids() == frozenset( + { + "@cf/moonshotai/kimi-k2.6", + "@cf/meta/llama-4-scout-17b-16e-instruct", + } + ) + + mock_get.assert_awaited_once_with( + _MODEL_SEARCH_URL, + params={"format": "openrouter"}, + headers={"Authorization": "Bearer test-cloudflare-token"}, + ) + + +@pytest.mark.asyncio +async def test_stream_uses_openai_chat_completions( + cloudflare_provider: CloudflareProvider, +) -> None: + delta = SimpleNamespace( + content="Hello from Cloudflare", + reasoning_content=None, + reasoning=None, + tool_calls=None, + ) + + with patch.object( + cloudflare_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=_stream(_chunk(delta)), + ) as mock_create: + events = [ + event async for event in cloudflare_provider.stream_response(_request()) + ] + + parsed = parse_sse_text("".join(events)) + assert any( + event.event == "content_block_delta" + and event.data.get("delta", {}).get("text") == "Hello from Cloudflare" + for event in parsed + ) + assert mock_create.call_args.kwargs["model"] == "@cf/moonshotai/kimi-k2.6" + assert mock_create.call_args.kwargs["stream"] is True + + +@pytest.mark.asyncio +async def test_stream_maps_cloudflare_reasoning_delta_to_thinking( + cloudflare_provider: CloudflareProvider, +) -> None: + delta = SimpleNamespace( + content=None, + reasoning_content=None, + reasoning="Cloudflare reasoning", + tool_calls=None, + ) + + with patch.object( + cloudflare_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=_stream(_chunk(delta)), + ): + events = [ + event async for event in cloudflare_provider.stream_response(_request()) + ] + + parsed = parse_sse_text("".join(events)) + assert any( + event.event == "content_block_delta" + and event.data.get("delta", {}).get("thinking") == "Cloudflare reasoning" + for event in parsed + ) + + +@pytest.mark.asyncio +async def test_stream_maps_openai_tool_calls_to_tool_use( + cloudflare_provider: CloudflareProvider, +) -> None: + tool_call = SimpleNamespace( + index=0, + id="call_1", + function=SimpleNamespace(name="echo", arguments='{"value":"x"}'), + ) + delta = SimpleNamespace( + content=None, + reasoning_content=None, + reasoning=None, + tool_calls=[tool_call], + ) + request = MessagesRequest.model_validate( + { + "model": "@cf/moonshotai/kimi-k2.6", + "messages": [{"role": "user", "content": "Use the tool"}], + "tools": [ + { + "name": "echo", + "description": "Echo a value", + "input_schema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + } + ], + } + ) + + with patch.object( + cloudflare_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=_stream(_chunk(delta, finish_reason="tool_calls")), + ): + events = [event async for event in cloudflare_provider.stream_response(request)] + + parsed = parse_sse_text("".join(events)) + assert any( + event.event == "content_block_start" + and event.data.get("content_block", {}).get("type") == "tool_use" + and event.data.get("content_block", {}).get("name") == "echo" + for event in parsed + ) + assert any( + event.event == "content_block_delta" + and event.data.get("delta", {}).get("partial_json") == '{"value":"x"}' + for event in parsed + ) diff --git a/tests/providers/test_codestral.py b/tests/providers/test_codestral.py new file mode 100644 index 0000000..72094e1 --- /dev/null +++ b/tests/providers/test_codestral.py @@ -0,0 +1,156 @@ +"""Tests for Mistral Codestral provider.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import CODESTRAL_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def make_request(**overrides): + return make_messages_request("devstral-small-latest", **overrides) + + +@pytest.fixture +def codestral_config(): + return ProviderConfig( + api_key="test_codestral_key", + base_url=CODESTRAL_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def codestral_provider(codestral_config): + return profiled_provider( + "mistral_codestral", codestral_config, rate_limiter=passthrough_rate_limiter() + ) + + +def test_init(codestral_config): + """Test provider initialization.""" + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = profiled_provider( + "mistral_codestral", + codestral_config, + rate_limiter=passthrough_rate_limiter(), + ) + assert provider._api_key == "test_codestral_key" + assert provider._base_url == CODESTRAL_DEFAULT_BASE + mock_openai.assert_called_once() + + +def test_default_base_url(): + assert CODESTRAL_DEFAULT_BASE == "https://codestral.mistral.ai/v1" + + +def test_build_request_body_basic(codestral_provider): + """Basic request body conversion works for Codestral.""" + req = make_request() + body = codestral_provider._build_request_body(req) + + assert body["model"] == "devstral-small-latest" + assert body["messages"][0]["role"] == "system" + + +def test_build_request_body_global_disable_blocks_reasoning_mapping(): + """Global disable disables reasoning replay in the converter.""" + provider = profiled_provider( + "mistral_codestral", + ProviderConfig( + api_key="test_codestral_key", + base_url=CODESTRAL_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + body = provider._build_request_body(req) + + roles = [m.get("role") for m in body.get("messages", [])] + assert "assistant_reasoning_content" not in roles + + +@pytest.mark.asyncio +async def test_stream_response_text(codestral_provider): + """Text content deltas are emitted as text blocks.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello back!", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + codestral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in codestral_provider.stream_response(req)] + + assert any( + '"text_delta"' in event and "Hello back!" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(codestral_provider): + """reasoning_content deltas are emitted as thinking blocks.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking...", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + codestral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in codestral_provider.stream_response(req)] + + assert any( + '"thinking_delta"' in event and "Thinking..." in event for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(codestral_provider): + """cleanup closes the OpenAI client.""" + codestral_provider._client = AsyncMock() + + await codestral_provider.cleanup() + + codestral_provider._client.close.assert_called_once() diff --git a/tests/providers/test_cohere.py b/tests/providers/test_cohere.py new file mode 100644 index 0000000..e151b83 --- /dev/null +++ b/tests/providers/test_cohere.py @@ -0,0 +1,291 @@ +"""Tests for Cohere Compatibility API provider.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.provider_catalog import COHERE_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def make_request(**overrides): + return make_messages_request("command-a-plus-05-2026", **overrides) + + +@pytest.fixture +def cohere_config(): + return ProviderConfig( + api_key="test_cohere_key", + base_url=COHERE_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def cohere_provider(cohere_config): + return profiled_provider( + "cohere", cohere_config, rate_limiter=passthrough_rate_limiter() + ) + + +def test_default_base_url_constant(): + assert COHERE_DEFAULT_BASE == "https://api.cohere.ai/compatibility/v1" + + +def test_init_uses_default_base_url_and_api_key(cohere_config): + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = profiled_provider( + "cohere", cohere_config, rate_limiter=passthrough_rate_limiter() + ) + + assert provider._api_key == "test_cohere_key" + assert provider._base_url == COHERE_DEFAULT_BASE + mock_openai.assert_called_once() + + +def test_init_strips_trailing_slash(cohere_config): + config = replace(cohere_config, base_url=f"{COHERE_DEFAULT_BASE}/") + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = profiled_provider( + "cohere", config, rate_limiter=passthrough_rate_limiter() + ) + + assert provider._base_url == COHERE_DEFAULT_BASE + + +def test_build_request_body_sanitizes_documented_unsupported_fields(cohere_provider): + with patch( + "free_claude_code.providers.openai_chat.request_policy.build_base_request_body" + ) as mock_convert: + mock_convert.return_value = { + "model": "command-a-plus-05-2026", + "messages": [{"role": "user", "name": "alice", "content": "hi"}], + "max_tokens": 42, + "store": True, + "metadata": {"trace": "abc"}, + "logit_bias": {"1": -100}, + "top_logprobs": 2, + "n": 4, + "modalities": ["text"], + "prediction": {"type": "content", "content": "x"}, + "audio": {"voice": "alloy"}, + "service_tier": "auto", + "parallel_tool_calls": True, + } + + body = cohere_provider._build_request_body(make_request()) + + assert body["messages"][0].get("name") is None + assert body["max_tokens"] == 42 + assert "max_completion_tokens" not in body + for key in ( + "audio", + "logit_bias", + "metadata", + "modalities", + "n", + "parallel_tool_calls", + "prediction", + "service_tier", + "store", + "top_logprobs", + ): + assert key not in body + + +def test_build_request_body_maps_thinking_enabled_to_reasoning_high(cohere_provider): + body = cohere_provider._build_request_body(make_request()) + + assert body["reasoning_effort"] == "high" + + +def test_build_request_body_preserves_replayed_reasoning_content(cohere_provider): + with patch( + "free_claude_code.providers.openai_chat.request_policy.build_base_request_body" + ) as mock_convert: + mock_convert.return_value = { + "model": "command-a-plus-05-2026", + "messages": [ + { + "role": "assistant", + "content": "answer", + "reasoning_content": "hidden chain", + } + ], + } + + body = cohere_provider._build_request_body(make_request()) + + assert body["messages"] == [ + { + "role": "assistant", + "content": "answer", + "reasoning_content": "hidden chain", + } + ] + assert body["reasoning_effort"] == "high" + + +def test_build_request_body_maps_thinking_disabled_to_reasoning_none(): + provider = profiled_provider( + "cohere", + ProviderConfig( + api_key="test_cohere_key", + base_url=COHERE_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + + body = provider._build_request_body(make_request()) + + assert body["reasoning_effort"] == "none" + + +def test_build_request_body_promotes_allowed_extra_body(cohere_provider): + req = make_request( + extra_body={ + "frequency_penalty": 0.1, + "presence_penalty": 0.2, + "response_format": {"type": "json_object"}, + "seed": 123, + } + ) + + body = cohere_provider._build_request_body(req) + + assert body["frequency_penalty"] == 0.1 + assert body["presence_penalty"] == 0.2 + assert body["response_format"] == {"type": "json_object"} + assert body["seed"] == 123 + assert "extra_body" not in body + + +def test_build_request_body_rejects_unsupported_extra_body(cohere_provider): + req = make_request(extra_body={"documents": [{"text": "x"}]}) + + with pytest.raises(InvalidRequestError, match="Unsupported"): + cohere_provider._build_request_body(req) + + +@pytest.mark.asyncio +async def test_stream_response_text(cohere_provider): + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello from Cohere", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + cohere_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event async for event in cohere_provider.stream_response(make_request()) + ] + + assert any( + '"text_delta"' in event and "Hello from Cohere" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_tool_call(cohere_provider): + mock_tc = MagicMock() + mock_tc.index = 0 + mock_tc.id = "call_1" + mock_tc.function = MagicMock() + mock_tc.function.name = "Read" + mock_tc.function.arguments = '{"file_path":"a.py"}' + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content=None, reasoning_content=None, tool_calls=[mock_tc]), + finish_reason="tool_calls", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + cohere_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event async for event in cohere_provider.stream_response(make_request()) + ] + + assert any( + '"content_block_start"' in event and '"tool_use"' in event for event in events + ) + assert any( + '"input_json_delta"' in event and "file_path" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(cohere_provider): + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking via Cohere", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + cohere_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event async for event in cohere_provider.stream_response(make_request()) + ] + + assert any( + '"thinking_delta"' in event and "Thinking via Cohere" in event + for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(cohere_provider): + cohere_provider._client = AsyncMock() + + await cohere_provider.cleanup() + + cohere_provider._client.close.assert_called_once() diff --git a/tests/providers/test_converter.py b/tests/providers/test_converter.py new file mode 100644 index 0000000..ed656ca --- /dev/null +++ b/tests/providers/test_converter.py @@ -0,0 +1,1046 @@ +import json + +import pytest + +from free_claude_code.core.anthropic import ( + AnthropicToOpenAIConverter, + OpenAIConversionError, + ReasoningReplayMode, + build_base_request_body, +) +from free_claude_code.core.anthropic.models import MessagesRequest + +# --- Mock Classes --- + + +class MockMessage: + def __init__(self, role, content, reasoning_content=None): + self.role = role + self.content = content + self.reasoning_content = reasoning_content + + +class MockBlock: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + self._data = kwargs + + def get(self, key, default=None): + return self._data.get(key, default) + + +class MockTool: + def __init__(self, name, description, input_schema=None): + self.name = name + self.description = description + self.input_schema = input_schema + + +# --- System Prompt Tests --- + + +def test_convert_system_prompt_str(): + system = "You are a helpful assistant." + result = AnthropicToOpenAIConverter.convert_system_prompt(system) + assert result == {"role": "system", "content": system} + + +def test_convert_system_prompt_list_text(): + system = [ + MockBlock(type="text", text="Part 1"), + MockBlock(type="text", text="Part 2"), + ] + result = AnthropicToOpenAIConverter.convert_system_prompt(system) + assert result == {"role": "system", "content": "Part 1\n\nPart 2"} + + +def test_convert_system_prompt_none(): + assert AnthropicToOpenAIConverter.convert_system_prompt(None) is None + + +# --- Tool Conversion Tests --- + + +def test_convert_tools(): + tools = [ + MockTool( + "get_weather", + "Get weather", + {"type": "object", "properties": {"loc": {"type": "string"}}}, + ), + MockTool("calculator", None, {"type": "object"}), + ] + result = AnthropicToOpenAIConverter.convert_tools(tools) + assert len(result) == 2 + + assert result[0]["type"] == "function" + assert result[0]["function"]["name"] == "get_weather" + assert result[0]["function"]["description"] == "Get weather" + assert result[0]["function"]["parameters"] == { + "type": "object", + "properties": {"loc": {"type": "string"}}, + } + + assert result[1]["function"]["name"] == "calculator" + assert result[1]["function"]["description"] == "" # Check default empty string + + +def test_convert_tool_without_input_schema_uses_empty_object_schema(): + tools = [MockTool("web_search", None)] + + result = AnthropicToOpenAIConverter.convert_tools(tools) + + assert result == [ + { + "type": "function", + "function": { + "name": "web_search", + "description": "", + "parameters": {"type": "object", "properties": {}}, + }, + } + ] + + +@pytest.mark.parametrize( + "tool_choice,expected", + [ + ( + {"type": "tool", "name": "echo_smoke"}, + {"type": "function", "function": {"name": "echo_smoke"}}, + ), + ({"type": "any"}, "required"), + ({"type": "auto"}, "auto"), + ({"type": "none"}, "none"), + ( + {"type": "function", "function": {"name": "already_openai"}}, + {"type": "function", "function": {"name": "already_openai"}}, + ), + ], +) +def test_convert_tool_choice(tool_choice, expected): + result = AnthropicToOpenAIConverter.convert_tool_choice(tool_choice) + assert result == expected + + +# --- Message Conversion Tests: User --- + + +def test_convert_user_message_str(): + messages = [MockMessage("user", "Hello world")] + result = AnthropicToOpenAIConverter.convert_messages(messages) + assert len(result) == 1 + assert result[0] == {"role": "user", "content": "Hello world"} + + +def test_convert_user_message_list_text(): + content = [ + MockBlock(type="text", text="Hello"), + MockBlock(type="text", text="World"), + ] + messages = [MockMessage("user", content)] + result = AnthropicToOpenAIConverter.convert_messages(messages) + assert len(result) == 1 + assert result[0] == {"role": "user", "content": "Hello\nWorld"} + + +def test_convert_user_message_tool_result_str(): + content = [ + MockBlock(type="tool_result", tool_use_id="tool_123", content="Result data") + ] + messages = [MockMessage("user", content)] + result = AnthropicToOpenAIConverter.convert_messages(messages) + assert len(result) == 1 + assert result[0] == { + "role": "tool", + "tool_call_id": "tool_123", + "content": "Result data", + } + + +def test_convert_user_message_tool_result_list(): + # Tool result content as a list of text blocks + tool_content = [ + {"type": "text", "text": "Line 1"}, + {"type": "text", "text": "Line 2"}, + ] + content = [ + MockBlock(type="tool_result", tool_use_id="tool_456", content=tool_content) + ] + messages = [MockMessage("user", content)] + result = AnthropicToOpenAIConverter.convert_messages(messages) + assert len(result) == 1 + assert result[0]["role"] == "tool" + assert result[0]["tool_call_id"] == "tool_456" + assert result[0]["content"] == "Line 1\nLine 2" + + +def test_convert_user_message_mixed_text_and_tool_result(): + # Note: Anthropic/OpenAI mapping usually separates these, but the converter handles lists + # User text usually comes before tool results in a turn, or after. + # The converter splits them into separate messages if they are different roles? + # Let's check logic: _convert_user_message returns a list of dicts. + content = [ + MockBlock(type="text", text="Here is the result:"), + MockBlock(type="tool_result", tool_use_id="tool_789", content="42"), + ] + messages = [MockMessage("user", content)] + result = AnthropicToOpenAIConverter.convert_messages(messages) + + # Order is preserved: user text first, then tool result. + assert len(result) == 2 + assert result[0] == {"role": "user", "content": "Here is the result:"} + assert result[1] == {"role": "tool", "tool_call_id": "tool_789", "content": "42"} + + +# --- Message Conversion Tests: Assistant --- + + +def test_convert_assistant_message_text_only(): + messages = [MockMessage("assistant", "I am ready.")] + result = AnthropicToOpenAIConverter.convert_messages(messages) + assert len(result) == 1 + assert result[0] == {"role": "assistant", "content": "I am ready."} + + +def test_convert_assistant_message_blocks_text(): + content = [MockBlock(type="text", text="Part A")] + messages = [MockMessage("assistant", content)] + result = AnthropicToOpenAIConverter.convert_messages(messages) + assert result[0] == {"role": "assistant", "content": "Part A"} + + +def test_convert_assistant_message_thinking(): + content = [ + MockBlock(type="thinking", thinking="I need to calculate this."), + MockBlock(type="text", text="The answer is 4."), + ] + messages = [MockMessage("assistant", content)] + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert len(result) == 1 + # Expecting tags + expected_content = ( + "\nI need to calculate this.\n\n\nThe answer is 4." + ) + assert result[0]["content"] == expected_content + assert "reasoning_content" not in result[0] + + +def test_convert_assistant_message_thinking_replays_reasoning_content(): + """Top-level reasoning replay avoids duplicating thinking into content.""" + content = [ + MockBlock(type="thinking", thinking="I need to calculate this."), + MockBlock(type="text", text="The answer is 4."), + ] + messages = [MockMessage("assistant", content)] + result = AnthropicToOpenAIConverter.convert_messages( + messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT + ) + + assert len(result) == 1 + assert result[0]["reasoning_content"] == "I need to calculate this." + assert result[0]["content"] == "The answer is 4." + assert "" not in result[0]["content"] + + +def test_convert_assistant_top_level_reasoning_content_is_preserved(): + messages = [ + MockMessage( + "assistant", + "The answer is 4.", + reasoning_content="I need to calculate this.", + ) + ] + result = AnthropicToOpenAIConverter.convert_messages( + messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT + ) + + assert result == [ + { + "role": "assistant", + "content": "The answer is 4.", + "reasoning_content": "I need to calculate this.", + } + ] + + +def test_convert_assistant_empty_top_level_reasoning_content_is_preserved(): + messages = [MockMessage("assistant", "The answer is 4.", reasoning_content="")] + + result = AnthropicToOpenAIConverter.convert_messages( + messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT + ) + + assert result == [ + { + "role": "assistant", + "content": "The answer is 4.", + "reasoning_content": "", + } + ] + + +def test_convert_assistant_thinking_tool_use_replays_top_level_reasoning(): + content = [ + MockBlock(type="thinking", thinking="I should call the tool."), + MockBlock( + type="tool_use", + id="call_reasoning", + name="search", + input={"query": "python"}, + ), + ] + messages = [ + MockMessage("assistant", content), + MockMessage( + "user", + [ + MockBlock( + type="tool_result", + tool_use_id="call_reasoning", + content="result", + ) + ], + ), + ] + result = AnthropicToOpenAIConverter.convert_messages( + messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT + ) + + assert len(result) == 2 + assert result[0]["content"] == "" + assert result[0]["reasoning_content"] == "I should call the tool." + assert "" not in result[0]["content"] + assert result[0]["tool_calls"][0]["id"] == "call_reasoning" + + +def test_convert_assistant_empty_thinking_replays_empty_reasoning_content(): + content = [ + MockBlock(type="thinking", thinking=""), + MockBlock(type="text", text="The answer is 4."), + ] + messages = [MockMessage("assistant", content)] + + result = AnthropicToOpenAIConverter.convert_messages( + messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT + ) + + assert result == [ + { + "role": "assistant", + "content": "The answer is 4.", + "reasoning_content": "", + } + ] + + +def test_convert_assistant_tool_use_replays_empty_reasoning_content(): + content = [ + MockBlock(type="thinking", thinking=""), + MockBlock(type="tool_use", id="call_empty", name="Read", input={}), + ] + messages = [ + MockMessage("assistant", content), + MockMessage( + "user", + [ + MockBlock( + type="tool_result", + tool_use_id="call_empty", + content="result", + ) + ], + ), + ] + + result = AnthropicToOpenAIConverter.convert_messages( + messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT + ) + + assert result[0]["content"] == "" + assert result[0]["reasoning_content"] == "" + assert result[0]["tool_calls"][0]["id"] == "call_empty" + + +def test_convert_assistant_message_thinking_removed_when_disabled(): + content = [ + MockBlock(type="thinking", thinking="I need to calculate this."), + MockBlock(type="text", text="The answer is 4."), + ] + messages = [MockMessage("assistant", content)] + result = AnthropicToOpenAIConverter.convert_messages( + messages, + reasoning_replay=ReasoningReplayMode.DISABLED, + ) + + assert len(result) == 1 + assert "reasoning_content" not in result[0] + assert "" not in result[0]["content"] + assert result[0]["content"] == "The answer is 4." + + +def test_convert_assistant_top_level_reasoning_stripped_when_disabled(): + messages = [ + MockMessage( + "assistant", + "The answer is 4.", + reasoning_content="I need to calculate this.", + ) + ] + result = AnthropicToOpenAIConverter.convert_messages( + messages, reasoning_replay=ReasoningReplayMode.DISABLED + ) + + assert result == [{"role": "assistant", "content": "The answer is 4."}] + + +def test_convert_assistant_message_tool_use(): + content = [ + MockBlock(type="text", text="I will call the tool."), + MockBlock( + type="tool_use", id="call_1", name="search", input={"query": "python"} + ), + ] + messages = [ + MockMessage("assistant", content), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="call_1", content="result")], + ), + ] + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert len(result) == 2 + msg = result[0] + assert msg["role"] == "assistant" + assert "I will call the tool." in msg["content"] + assert "tool_calls" in msg + assert len(msg["tool_calls"]) == 1 + tc = msg["tool_calls"][0] + assert tc["id"] == "call_1" + assert tc["function"]["name"] == "search" + assert json.loads(tc["function"]["arguments"]) == {"query": "python"} + + +def test_convert_assistant_tool_use_preserves_extra_content(): + content = [ + MockBlock( + type="tool_use", + id="call_1", + name="search", + input={"query": "python"}, + extra_content={"google": {"thought_signature": "sig"}}, + ), + ] + messages = [ + MockMessage("assistant", content), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="call_1", content="result")], + ), + ] + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert result[0]["tool_calls"][0]["extra_content"] == { + "google": {"thought_signature": "sig"} + } + + +def test_convert_assistant_message_empty_content(): + # Verify that empty content becomes a single space (NIM requirement) + # if no tool calls are present. + content = [] + messages = [MockMessage("assistant", content)] + result = AnthropicToOpenAIConverter.convert_messages(messages) + assert result[0]["content"] == " " + + +def test_convert_assistant_message_tool_use_no_text(): + # If tool usage exists, content can be empty string? + # Logic: if not content_str and not tool_calls: content_str = " " + # So if tool_calls exist, content_str can be empty string? + # Actually code says: if not content_str and not tool_calls. + # So if tool_calls is present, content_str remains "" (empty). + + content = [MockBlock(type="tool_use", id="call_2", name="test", input={})] + messages = [ + MockMessage("assistant", content), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="call_2", content="result")], + ), + ] + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert ( + result[0]["content"] == "" + ) # Should be empty string, not space, because tools exist + assert len(result[0]["tool_calls"]) == 1 + + +def test_convert_mixed_blocks_and_types_and_roles(): + # comprehensive flow + messages = [ + MockMessage("user", "Start"), + MockMessage( + "assistant", + [ + MockBlock(type="thinking", thinking="Thinking..."), + MockBlock(type="text", text="Here is a tool."), + ], + ), + MockMessage( + "assistant", [MockBlock(type="tool_use", id="t1", name="f", input={})] + ), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="t1", content="result")], + ), + ] + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert len(result) == 4 + assert result[0]["role"] == "user" + assert "" in result[1]["content"] + assert result[2]["tool_calls"][0]["id"] == "t1" + + +# --- Edge Cases --- + + +def test_get_block_attr_defaults(): + # Test helper directly + from free_claude_code.core.anthropic import get_block_attr + + assert get_block_attr({}, "missing", "default") == "default" + assert get_block_attr(object(), "missing", "default") == "default" + + +def test_input_not_dict(): + # Tool input might not be a dict (e.g. malformed or string) + content = [MockBlock(type="tool_use", id="call_x", name="f", input="some_string")] + messages = [ + MockMessage("assistant", content), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="call_x", content="result")], + ), + ] + result = AnthropicToOpenAIConverter.convert_messages(messages) + # The converter calls json.dumps(tool_input) if dict, else str(tool_input) + # So it should be "some_string" + assert result[0]["tool_calls"][0]["function"]["arguments"] == "some_string" + + +# --- Parametrized Edge Case Tests --- + + +@pytest.mark.parametrize( + "system_input,expected", + [ + ("You are helpful.", {"role": "system", "content": "You are helpful."}), + ( + [MockBlock(type="text", text="A"), MockBlock(type="text", text="B")], + {"role": "system", "content": "A\n\nB"}, + ), + (None, None), + ("", {"role": "system", "content": ""}), + ([], None), + ], + ids=["string", "list_text", "none", "empty_string", "empty_list"], +) +def test_convert_system_prompt_parametrized(system_input, expected): + """Parametrized system prompt conversion covering edge cases.""" + result = AnthropicToOpenAIConverter.convert_system_prompt(system_input) + assert result == expected + + +@pytest.mark.parametrize( + "content,expected_content", + [ + ("Hello world", "Hello world"), + ("", ""), + ([MockBlock(type="text", text="A"), MockBlock(type="text", text="B")], "A\nB"), + ([MockBlock(type="text", text="")], ""), + ], + ids=["simple_string", "empty_string", "list_blocks", "empty_text_block"], +) +def test_convert_user_message_parametrized(content, expected_content): + """Parametrized user message conversion.""" + messages = [MockMessage("user", content)] + result = AnthropicToOpenAIConverter.convert_messages(messages) + assert len(result) >= 1 + assert result[0]["content"] == expected_content + + +def test_convert_assistant_message_unknown_block_type(): + """Unknown block types should be silently skipped.""" + content = [ + MockBlock(type="unknown_type", data="something"), + MockBlock(type="text", text="visible"), + ] + messages = [MockMessage("assistant", content)] + result = AnthropicToOpenAIConverter.convert_messages(messages) + assert len(result) == 1 + assert "visible" in result[0]["content"] + + +def test_convert_tool_use_none_input(): + """Tool use with None input should not crash.""" + content = [MockBlock(type="tool_use", id="call_n", name="test", input=None)] + messages = [ + MockMessage("assistant", content), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="call_n", content="result")], + ), + ] + result = AnthropicToOpenAIConverter.convert_messages(messages) + assert len(result) == 2 + assert "tool_calls" in result[0] + + +def test_convert_assistant_interleaved_order_preserved(): + """Interleaved thinking, text, tool_use should preserve thinking+text order in content. + + Bug: Current implementation collects all thinking, then all text, then tool_calls. + Original order [thinking, text, thinking, tool_use] becomes [all thinking, all text, tool_calls], + losing the interleaving. Content string should reflect original block order for thinking+text. + Tool calls stay at end (API constraint). + """ + content = [ + MockBlock(type="thinking", thinking="First thought."), + MockBlock(type="text", text="Here is the answer."), + MockBlock(type="thinking", thinking="Second thought."), + MockBlock(type="tool_use", id="call_1", name="search", input={"q": "x"}), + ] + messages = [ + MockMessage("assistant", content), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="call_1", content="result")], + ), + ] + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert len(result) == 2 + msg = result[0] + # Expected: thinking1, text, thinking2 in that order within content; tool_calls at end + expected_content = "\nFirst thought.\n\n\nHere is the answer.\n\n\nSecond thought.\n" + assert msg["content"] == expected_content, ( + f"Interleaved order lost. Got: {msg['content']!r}" + ) + assert len(msg["tool_calls"]) == 1 + + +def test_convert_user_message_text_before_tool_result_order(): + """User message with text then tool_result should preserve order: user text first, then tool. + + Bug: Current implementation emits tool_result immediately, then user text at end. + Anthropic order is typically: user says something, then provides tool results. + """ + content = [ + MockBlock(type="text", text="Please use this result:"), + MockBlock(type="tool_result", tool_use_id="t1", content="42"), + ] + messages = [MockMessage("user", content)] + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert len(result) == 2 + # Expected: user text first, then tool result + assert result[0]["role"] == "user" + assert result[0]["content"] == "Please use this result:" + assert result[1]["role"] == "tool" + assert result[1]["tool_call_id"] == "t1" + + +def test_convert_multiple_tool_results(): + """Multiple tool results in a single user message.""" + content = [ + MockBlock(type="tool_result", tool_use_id="t1", content="Result 1"), + MockBlock(type="tool_result", tool_use_id="t2", content="Result 2"), + ] + messages = [MockMessage("user", content)] + result = AnthropicToOpenAIConverter.convert_messages(messages) + assert len(result) == 2 + assert result[0]["tool_call_id"] == "t1" + assert result[1]["tool_call_id"] == "t2" + + +def test_convert_user_message_tool_result_dict_as_json(): + content = [ + MockBlock( + type="tool_result", + tool_use_id="t_dict", + content={"ok": True, "count": 2}, + ), + ] + messages = [MockMessage("user", content)] + result = AnthropicToOpenAIConverter.convert_messages(messages) + assert result[0]["role"] == "tool" + assert result[0]["content"] == '{"ok": true, "count": 2}' + + +def test_assistant_redacted_thinking_omitted_from_openai_chat(): + """Opaque redacted_thinking is not materialized as content or reasoning_content.""" + content = [ + MockBlock(type="redacted_thinking", data="secret-opaque"), + MockBlock(type="text", text="Visible."), + ] + messages = [MockMessage("assistant", content)] + result = AnthropicToOpenAIConverter.convert_messages( + messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT + ) + assert result[0]["content"] == "Visible." + assert "secret-opaque" not in result[0]["content"] + assert "reasoning_content" not in result[0] + + +def test_convert_user_message_image_raises(): + content = [ + MockBlock(type="image", source={"type": "url", "url": "https://example.com/x"}) + ] + messages = [MockMessage("user", content)] + with pytest.raises(OpenAIConversionError): + AnthropicToOpenAIConverter.convert_messages(messages) + + +def test_convert_assistant_text_after_tool_use_requires_matching_tool_result(): + """Dangling post-tool assistant text cannot be replayed as valid OpenAI chat.""" + content = [ + MockBlock(type="tool_use", id="call_z", name="Read", input={}), + MockBlock(type="text", text="After tool"), + ] + messages = [MockMessage("assistant", content)] + with pytest.raises(OpenAIConversionError, match="missing tool_result"): + AnthropicToOpenAIConverter.convert_messages(messages) + + +def test_convert_assistant_text_after_tool_use_inserts_after_tool_results(): + messages = [ + MockMessage( + "assistant", + [ + MockBlock(type="tool_use", id="call_z", name="Read", input={}), + MockBlock(type="text", text="Post-tool commentary"), + ], + ), + MockMessage( + "user", + [ + MockBlock( + type="tool_result", + tool_use_id="call_z", + content="file contents", + ) + ], + ), + ] + result = AnthropicToOpenAIConverter.convert_messages(messages) + assert result[0]["role"] == "assistant" and "tool_calls" in result[0] + assert result[1]["role"] == "tool" and result[1]["tool_call_id"] == "call_z" + assert result[2] == {"role": "assistant", "content": "Post-tool commentary"} + + +def test_unrelated_user_text_before_tool_result_is_buffered_until_after_tool_result(): + messages = [ + MockMessage( + "assistant", + [MockBlock(type="tool_use", id="call_z", name="Read", input={})], + ), + MockMessage("user", "Please also summarize it."), + MockMessage( + "user", + [ + MockBlock( + type="tool_result", + tool_use_id="call_z", + content="file contents", + ) + ], + ), + ] + + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert [message["role"] for message in result] == ["assistant", "tool", "user"] + assert result[0]["tool_calls"][0]["id"] == "call_z" + assert result[1]["tool_call_id"] == "call_z" + assert result[2]["content"] == "Please also summarize it." + + +def test_unrelated_assistant_text_before_tool_result_is_buffered_until_after_tool_result(): + messages = [ + MockMessage( + "assistant", + [MockBlock(type="tool_use", id="call_z", name="Read", input={})], + ), + MockMessage("assistant", "Waiting for the result."), + MockMessage( + "user", + [ + MockBlock( + type="tool_result", + tool_use_id="call_z", + content="file contents", + ) + ], + ), + ] + + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert [message["role"] for message in result] == [ + "assistant", + "tool", + "assistant", + ] + assert result[0]["tool_calls"][0]["id"] == "call_z" + assert result[1]["tool_call_id"] == "call_z" + assert result[2]["content"] == "Waiting for the result." + + +def test_user_text_in_tool_result_message_is_replayed_after_tool_sequence(): + messages = [ + MockMessage( + "assistant", + [ + MockBlock(type="tool_use", id="call_z", name="Read", input={}), + MockBlock(type="text", text="Post-tool commentary"), + ], + ), + MockMessage( + "user", + [ + MockBlock(type="text", text="Use this result too."), + MockBlock( + type="tool_result", + tool_use_id="call_z", + content="file contents", + ), + ], + ), + ] + + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert [message["role"] for message in result] == [ + "assistant", + "tool", + "assistant", + "user", + ] + assert result[1]["tool_call_id"] == "call_z" + assert result[2]["content"] == "Post-tool commentary" + assert result[3]["content"] == "Use this result too." + + +def test_nested_pending_tool_use_waits_for_its_own_tool_result_before_deferred_text(): + messages = [ + MockMessage( + "assistant", + [MockBlock(type="tool_use", id="call_a", name="ReadA", input={})], + ), + MockMessage( + "assistant", + [ + MockBlock(type="tool_use", id="call_b", name="ReadB", input={}), + MockBlock(type="text", text="Post-call-b commentary"), + ], + ), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="call_a", content="result a")], + ), + MockMessage( + "user", + [MockBlock(type="tool_result", tool_use_id="call_b", content="result b")], + ), + ] + + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert [message["role"] for message in result] == [ + "assistant", + "tool", + "assistant", + "tool", + "assistant", + ] + assert result[0]["tool_calls"][0]["id"] == "call_a" + assert result[1]["tool_call_id"] == "call_a" + assert result[2]["tool_calls"][0]["id"] == "call_b" + assert result[3]["tool_call_id"] == "call_b" + assert result[4]["content"] == "Post-call-b commentary" + + +def test_nested_pending_uses_early_nested_tool_result_after_outer_result(): + messages = [ + MockMessage( + "assistant", + [MockBlock(type="tool_use", id="call_a", name="ReadA", input={})], + ), + MockMessage( + "assistant", + [ + MockBlock(type="tool_use", id="call_b", name="ReadB", input={}), + MockBlock(type="text", text="Post-call-b commentary"), + ], + ), + MockMessage( + "user", + [ + MockBlock(type="tool_result", tool_use_id="call_b", content="result b"), + MockBlock(type="tool_result", tool_use_id="call_a", content="result a"), + ], + ), + ] + + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert [message["role"] for message in result] == [ + "assistant", + "tool", + "assistant", + "tool", + "assistant", + ] + assert result[0]["tool_calls"][0]["id"] == "call_a" + assert result[1]["tool_call_id"] == "call_a" + assert result[2]["tool_calls"][0]["id"] == "call_b" + assert result[3]["tool_call_id"] == "call_b" + assert result[4]["content"] == "Post-call-b commentary" + + +def test_multi_tool_turn_waits_for_all_results_before_deferred_text(): + messages = [ + MockMessage( + "assistant", + [ + MockBlock(type="tool_use", id="call_a", name="ReadA", input={}), + MockBlock(type="tool_use", id="call_b", name="ReadB", input={}), + MockBlock(type="text", text="Both tools are done."), + ], + ), + MockMessage( + "user", + [ + MockBlock(type="tool_result", tool_use_id="call_b", content="result b"), + MockBlock(type="tool_result", tool_use_id="call_a", content="result a"), + ], + ), + ] + + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert [message["role"] for message in result] == [ + "assistant", + "tool", + "tool", + "assistant", + ] + assert [message["tool_call_id"] for message in result[1:3]] == [ + "call_a", + "call_b", + ] + assert result[3]["content"] == "Both tools are done." + + +def test_nested_pending_buffers_user_text_until_all_prior_tool_sequences_complete(): + messages = [ + MockMessage( + "assistant", + [MockBlock(type="tool_use", id="call_a", name="ReadA", input={})], + ), + MockMessage( + "assistant", + [ + MockBlock(type="tool_use", id="call_b", name="ReadB", input={}), + MockBlock(type="text", text="Post-call-b commentary"), + ], + ), + MockMessage( + "user", + [ + MockBlock(type="text", text="Use both results."), + MockBlock( + type="tool_result", + tool_use_id="call_a", + content="result a", + ), + MockBlock( + type="tool_result", + tool_use_id="call_b", + content="result b", + ), + ], + ), + ] + + result = AnthropicToOpenAIConverter.convert_messages(messages) + + assert [message["role"] for message in result] == [ + "assistant", + "tool", + "assistant", + "tool", + "assistant", + "user", + ] + assert result[1]["tool_call_id"] == "call_a" + assert result[3]["tool_call_id"] == "call_b" + assert result[4]["content"] == "Post-call-b commentary" + assert result[5]["content"] == "Use both results." + + +def test_openai_build_accepts_declared_native_top_level_hints() -> None: + """OpenAI conversion ignores known non-OpenAI hints (e.g. context_management) without 400.""" + req = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "h"}], + "context_management": {"edits": []}, + "output_config": {"foo": 1}, + "mcp_servers": [{"type": "url", "url": "https://x.com"}], + } + ) + body = build_base_request_body(req, default_max_tokens=100) + assert "context_management" not in body + assert "output_config" not in body + assert "mcp_servers" not in body + assert body["model"] == "m" + + +def test_openai_build_rejects_unknown_top_level_extras() -> None: + """Truly unknown keys must still be rejected (not dropped silently).""" + req = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "h"}], + "experimental_client_only_passthrough": True, + } + ) + with pytest.raises(OpenAIConversionError, match="top-level request fields"): + build_base_request_body(req) + + +@pytest.mark.parametrize( + "content", + [ + [MockBlock(type="server_tool_use", id="1", name="web_search", input={})], + [MockBlock(type="web_search_tool_result", tool_use_id="1", content=[])], + [ + MockBlock( + type="web_fetch_tool_result", + tool_use_id="1", + content={"type": "web_fetch_result", "url": "https://a.com/x"}, + ) + ], + ], +) +def test_convert_assistant_server_tool_blocks_raise(content) -> None: + messages = [MockMessage("assistant", content)] + with pytest.raises(OpenAIConversionError, match="server tool"): + AnthropicToOpenAIConverter.convert_messages(messages) diff --git a/tests/providers/test_deepseek.py b/tests/providers/test_deepseek.py new file mode 100644 index 0000000..d074857 --- /dev/null +++ b/tests/providers/test_deepseek.py @@ -0,0 +1,1159 @@ +"""Tests for DeepSeek OpenAI-compatible Chat Completions provider.""" + +import logging +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.config.provider_catalog import DEEPSEEK_DEFAULT_BASE +from free_claude_code.core.anthropic.models import ( + ContentBlockImage, + Message, + MessagesRequest, + Tool, +) +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.deepseek import DeepSeekProvider +from tests.providers.support import passthrough_rate_limiter + + +@pytest.fixture +def deepseek_config(): + return ProviderConfig( + api_key="test_deepseek_key", + base_url=DEEPSEEK_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def deepseek_provider(deepseek_config): + return DeepSeekProvider(deepseek_config, rate_limiter=passthrough_rate_limiter()) + + +def test_default_base_url_alias(): + assert DEEPSEEK_DEFAULT_BASE == "https://api.deepseek.com" + + +def test_init(deepseek_config): + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_client: + provider = DeepSeekProvider( + deepseek_config, rate_limiter=passthrough_rate_limiter() + ) + assert provider._api_key == "test_deepseek_key" + assert provider._base_url == "https://api.deepseek.com" + assert mock_client.called + + +def test_build_request_body_openai_chat_shape(deepseek_provider): + request = MessagesRequest( + model="deepseek-v4-pro", + max_tokens=100, + messages=[Message(role="user", content="Hello")], + system="S", + ) + body = deepseek_provider._build_request_body(request) + assert body["model"] == "deepseek-v4-pro" + assert "stream" not in body + assert body["messages"][0] == {"role": "system", "content": "S"} + assert body["messages"][1]["role"] == "user" + assert body["messages"][1] == {"role": "user", "content": "Hello"} + assert body["max_tokens"] == 100 + assert "stream_options" not in body + + +def test_build_request_body_default_max_tokens(deepseek_provider): + request = MessagesRequest( + model="m", + messages=[Message(role="user", content="x")], + ) + body = deepseek_provider._build_request_body(request) + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + +def test_build_request_body_thinking_enabled(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "thinking": {"type": "enabled", "budget_tokens": 2000}, + } + ) + body = deepseek_provider._build_request_body(request) + assert body["extra_body"]["thinking"] == {"type": "enabled"} + + +def test_build_request_body_tool_list_keeps_thinking(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "tools": [ + { + "name": "Read", + "description": "Read a file", + "input_schema": {"type": "object", "properties": {}}, + } + ], + "thinking": {"type": "enabled", "budget_tokens": 2000}, + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert body["tools"][0]["function"]["name"] == "Read" + + +def test_build_request_body_tool_choice_keeps_thinking(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "tool_choice": {"type": "auto"}, + "thinking": {"type": "enabled", "budget_tokens": 2000}, + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert body["tool_choice"] == "auto" + + +def test_build_request_body_forced_tool_choice_downgrades_to_auto( + deepseek_provider, +): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "tool_choice": {"type": "tool", "name": "Read"}, + "tools": [ + { + "name": "Read", + "description": "Read a file", + "input_schema": {"type": "object", "properties": {}}, + } + ], + "thinking": {"type": "enabled", "budget_tokens": 2000}, + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert body["tool_choice"] == "auto" + + +def test_build_request_body_respects_global_thinking_disable(): + provider = DeepSeekProvider( + ProviderConfig( + api_key="k", + base_url=DEEPSEEK_DEFAULT_BASE, + rate_limit=1, + rate_window=1, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "thinking": {"type": "enabled", "budget_tokens": 1}, + } + ) + body = provider._build_request_body(request) + assert "extra_body" not in body + assert "stream_options" not in body + + +def test_preserve_unsigned_thinking_when_thinking_on(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "plain", + "signature": None, + }, + {"type": "text", "text": "out"}, + ], + } + ], + } + ) + body = deepseek_provider._build_request_body(request) + assert body["messages"][0]["content"] == "out" + assert body["messages"][0]["reasoning_content"] == "plain" + + +def test_strip_redacted_thinking_when_thinking_on(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "redacted_thinking", "data": "opaque"}, + {"type": "text", "text": "out"}, + ], + } + ], + } + ) + body = deepseek_provider._build_request_body(request) + assert body["messages"][0] == {"role": "assistant", "content": "out"} + + +def test_tool_history_with_replayable_thinking_preserves_thinking(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "hidden", + "signature": "sig_123", + }, + {"type": "redacted_thinking", "data": "opaque"}, + { + "type": "tool_use", + "id": "t1", + "name": "Read", + "input": {"file_path": "x"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "ok", + } + ], + }, + ], + "thinking": {"type": "enabled", "budget_tokens": 2000}, + "context_management": { + "edits": [{"type": "clear_thinking_20251015", "keep": "all"}] + }, + "output_config": {"effort": "high"}, + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert "context_management" not in body + assert "output_config" not in body + assistant = body["messages"][0] + assert assistant["content"] == "" + assert assistant["reasoning_content"] == "hidden" + assert assistant["tool_calls"][0]["function"]["name"] == "Read" + assert assistant["tool_calls"][0]["function"]["arguments"] == '{"file_path": "x"}' + assert body["messages"][1] == { + "role": "tool", + "tool_call_id": "t1", + "content": "ok", + } + + +def test_tool_history_with_unsigned_thinking_preserves_thinking(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "plain"}, + { + "type": "tool_use", + "id": "t1", + "name": "Read", + "input": {"file_path": "x"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "ok", + } + ], + }, + ], + "thinking": {"type": "enabled"}, + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert body["messages"][0]["reasoning_content"] == "plain" + + +def test_tool_history_without_thinking_disables_thinking_and_hints(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Read", + "input": {"file_path": "x"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "ok", + } + ], + }, + ], + "tools": [ + { + "name": "Read", + "description": "Read a file", + "input_schema": {"type": "object", "properties": {}}, + } + ], + "tool_choice": {"type": "auto"}, + "thinking": {"type": "enabled", "budget_tokens": 2000}, + "context_management": { + "edits": [ + {"type": "clear_thinking_20251015", "keep": "all"}, + {"type": "other_edit", "keep": "all"}, + ], + "other": True, + }, + "output_config": {"effort": "high", "format": "text"}, + } + ) + + body = deepseek_provider._build_request_body(request) + + assert "extra_body" not in body + assert "context_management" not in body + assert "output_config" not in body + assert body["tools"][0]["function"]["name"] == "Read" + assert body["tool_choice"] == "auto" + assert body["messages"][0]["tool_calls"][0]["function"]["name"] == "Read" + assert body["messages"][1]["role"] == "tool" + + +def test_tool_history_with_empty_thinking_preserves_reasoning_state(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": ""}, + { + "type": "tool_use", + "id": "t1", + "name": "Read", + "input": {"file_path": "x"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "ok", + } + ], + }, + ], + "thinking": {"type": "enabled"}, + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert body["messages"][0]["reasoning_content"] == "" + assert body["messages"][0]["tool_calls"][0]["function"]["name"] == "Read" + + +def test_tool_history_with_empty_top_level_reasoning_preserves_reasoning_state( + deepseek_provider, +): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Read", + "input": {"file_path": "x"}, + }, + ], + "reasoning_content": "", + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "ok", + } + ], + }, + ], + "thinking": {"type": "enabled"}, + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert body["messages"][0]["reasoning_content"] == "" + assert body["messages"][0]["tool_calls"][0]["function"]["name"] == "Read" + + +def test_thinking_off_strips_thinking_history(): + provider = DeepSeekProvider( + ProviderConfig( + api_key="k", + base_url=DEEPSEEK_DEFAULT_BASE, + rate_limit=1, + rate_window=1, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "sec"}, + {"type": "text", "text": "hi"}, + ], + } + ], + } + ) + body = provider._build_request_body(request) + assert "reasoning_content" not in body["messages"][0] + assert "sec" not in str(body["messages"]) + + +def test_passthrough_tool_use_and_result(deepseek_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "n", + "input": {"a": 1}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "ok", + } + ], + }, + ], + } + ) + body = deepseek_provider._build_request_body(request) + assert body["messages"][0]["tool_calls"][0]["function"]["name"] == "n" + assert body["messages"][1]["role"] == "tool" + + +def test_preflight_strips_user_image(): + """Image blocks are silently stripped (DeepSeek lacks vision); request must not fail.""" + request = MessagesRequest( + model="m", + messages=[ + Message( + role="user", + content=[ + ContentBlockImage( + type="image", + source={ + "type": "base64", + "media_type": "image/png", + "data": "YQ==", + }, + ) + ], + ) + ], + ) + provider = DeepSeekProvider( + ProviderConfig( + api_key="k", + base_url=DEEPSEEK_DEFAULT_BASE, + rate_limit=1, + rate_window=1, + ), + rate_limiter=passthrough_rate_limiter(), + ) + # Should not raise; image is stripped. + provider.preflight_stream(request, thinking_enabled=True) + body = provider._build_request_body(request) + content = body["messages"][0]["content"] + assert "attachment omitted" in content.lower() + assert "image or document inputs" in content.lower() + + +def test_preflight_rejects_mcp_servers(): + request = MessagesRequest( + model="m", + messages=[Message(role="user", content="x")], + mcp_servers=[{"type": "url", "url": "https://x"}], + ) + provider = DeepSeekProvider( + ProviderConfig( + api_key="k", + base_url=DEEPSEEK_DEFAULT_BASE, + rate_limit=1, + rate_window=1, + ), + rate_limiter=passthrough_rate_limiter(), + ) + with pytest.raises(InvalidRequestError, match="mcp_servers"): + provider.preflight_stream(request) + + +def test_preflight_rejects_listed_server_tools_in_tools_list(): + request = MessagesRequest( + model="m", + messages=[Message(role="user", content="x")], + tools=[Tool(name="web_search", type="web_search_20250305", input_schema={})], + ) + provider = DeepSeekProvider( + ProviderConfig( + api_key="k", + base_url=DEEPSEEK_DEFAULT_BASE, + rate_limit=1, + rate_window=1, + ), + rate_limiter=passthrough_rate_limiter(), + ) + with pytest.raises(InvalidRequestError, match="web_search"): + provider.preflight_stream(request) + + +def test_preflight_rejects_server_tool_result_blocks(): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "server_tool_use", + "id": "s1", + "name": "web_search", + "input": {"q": "a"}, + }, + { + "type": "web_search_tool_result", + "tool_use_id": "s1", + "content": [], + }, + ], + } + ], + } + ) + provider = DeepSeekProvider( + ProviderConfig( + api_key="k", + base_url=DEEPSEEK_DEFAULT_BASE, + rate_limit=1, + rate_window=1, + ), + rate_limiter=passthrough_rate_limiter(), + ) + with pytest.raises(InvalidRequestError, match=r"web_search_tool_result|server"): + provider.preflight_stream(request) + + +def test_reasoning_content_replayed_to_openai_chat(deepseek_provider): + request = MessagesRequest( + model="m", + messages=[ + Message( + role="assistant", + content="hi", + reasoning_content="r", + ) + ], + ) + body = deepseek_provider._build_request_body(request) + assert body["messages"][0]["reasoning_content"] == "r" + + +@pytest.mark.asyncio +async def test_stream_uses_chat_completions_and_maps_cache_usage(deepseek_provider): + request = MessagesRequest( + model="m", + messages=[Message(role="user", content="hi")], + ) + + async def fake_stream(): + yield SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace( + content="hello", reasoning_content=None, tool_calls=None + ), + finish_reason=None, + ) + ], + usage=None, + ) + yield SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace( + content=None, reasoning_content=None, tool_calls=None + ), + finish_reason="stop", + ) + ], + usage=None, + ) + yield SimpleNamespace( + choices=[], + usage=SimpleNamespace( + completion_tokens=3, + prompt_tokens=30, + prompt_cache_hit_tokens=10, + prompt_cache_miss_tokens=20, + ), + ) + + create = AsyncMock(return_value=fake_stream()) + with patch.object(deepseek_provider._client.chat.completions, "create", create): + chunks = [ + chunk + async for chunk in deepseek_provider.stream_response( + request, input_tokens=7, request_id="r1" + ) + ] + + create.assert_awaited_once() + await_args = create.await_args + assert await_args is not None + assert await_args.kwargs["model"] == "m" + assert await_args.kwargs["stream"] is True + assert await_args.kwargs["stream_options"] == {"include_usage": True} + parsed = parse_sse_text("".join(chunks)) + usage = next( + event.data["usage"] for event in parsed if event.event == "message_delta" + ) + assert usage == { + "input_tokens": 30, + "output_tokens": 3, + "cache_read_input_tokens": 10, + "cache_creation_input_tokens": 20, + } + + +def test_preserves_extra_body_for_openai_chat_request(deepseek_provider): + raw = { + "model": "m", + "max_tokens": 3, + "messages": [{"role": "user", "content": "x"}], + "extra_body": {"note": 1}, + } + r = MessagesRequest.model_validate(raw) + body = deepseek_provider._build_request_body(r) + assert body["extra_body"] == {"note": 1, "thinking": {"type": "enabled"}} + + +def test_normalizes_tool_result_content_array_to_string(deepseek_provider): + """Test that tool_result content arrays are normalized to strings for DeepSeek API.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "list_dir", + "input": {"path": "/"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": [ + {"type": "text", "text": "file1.txt"}, + {"type": "text", "text": "file2.txt"}, + ], + } + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + tool_result = body["messages"][1] + assert tool_result["role"] == "tool" + assert isinstance(tool_result["content"], str) + assert "file1.txt" in tool_result["content"] + assert "file2.txt" in tool_result["content"] + + +def test_strips_document_blocks_for_deepseek(deepseek_provider): + """Document blocks (e.g. PDFs from Claude Code) are stripped since DeepSeek can't process them.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": "PDF text extracted", + }, + { + "type": "document", + "source": {"type": "file", "file_id": "file_abc"}, + "cache_control": {"type": "ephemeral"}, + }, + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["messages"][0] == { + "role": "tool", + "tool_call_id": "t1", + "content": "PDF text extracted", + } + + +def test_strips_image_blocks_for_deepseek(deepseek_provider): + """Image blocks are stripped for DeepSeek since it doesn't support vision.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "describe this"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc", + }, + }, + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + assert body["messages"][0] == {"role": "user", "content": "describe this"} + + +def test_normalizes_tool_result_content_dict_to_string(deepseek_provider): + """Test that tool_result content dicts are normalized to JSON strings.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "get_data", + "input": {}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": {"status": "success", "data": [1, 2, 3]}, + } + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + tool_result = body["messages"][1] + assert tool_result["role"] == "tool" + assert isinstance(tool_result["content"], str) + assert "status" in tool_result["content"] + assert "success" in tool_result["content"] + + +def test_strips_image_block_inside_tool_result(deepseek_provider): + """Image blocks nested inside tool_result.content are stripped, not rejected.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Read", + "input": {"path": "shot.png"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": [ + {"type": "text", "text": "screenshot saved"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc", + }, + }, + ], + } + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + tool_result = body["messages"][1] + assert tool_result["role"] == "tool" + # After stripping + string-normalization, no base64/image marker survives. + assert isinstance(tool_result["content"], str) + assert "screenshot saved" in tool_result["content"] + assert "base64" not in tool_result["content"] + assert "abc" not in tool_result["content"] + + +def test_image_only_tool_result_replaced_with_placeholder(deepseek_provider): + """A tool_result whose only inner block is an image becomes a placeholder string.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Screenshot", + "input": {}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc", + }, + }, + ], + } + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + tool_result = body["messages"][1] + assert tool_result["role"] == "tool" + assert isinstance(tool_result["content"], str) + assert tool_result["content"] != "" + assert "attachment omitted" in tool_result["content"].lower() + assert "image or document inputs" in tool_result["content"].lower() + + +def test_document_only_tool_result_replaced_with_generic_placeholder( + deepseek_provider, +): + """A document-only tool_result uses the generic attachment placeholder.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Read", + "input": {"file_path": "paper.pdf"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": [ + { + "type": "document", + "source": { + "type": "file", + "file_id": "file_pdf", + }, + }, + ], + } + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + tool_result = body["messages"][1] + assert tool_result["role"] == "tool" + assert isinstance(tool_result["content"], str) + assert "attachment omitted" in tool_result["content"].lower() + assert "document inputs" in tool_result["content"].lower() + assert "image omitted" not in tool_result["content"].lower() + + +def test_image_only_message_replaced_with_placeholder(deepseek_provider): + """A top-level image-only message remains non-empty after stripping.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc", + }, + }, + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + content = body["messages"][0]["content"] + assert "attachment omitted" in content.lower() + assert "image or document inputs" in content.lower() + + +def test_document_only_message_replaced_with_placeholder(deepseek_provider): + """A top-level document-only message remains non-empty after stripping.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "document", + "source": {"type": "file", "file_id": "file_pdf"}, + }, + ], + }, + ], + } + ) + + body = deepseek_provider._build_request_body(request) + + content = body["messages"][0]["content"] + assert "attachment omitted" in content.lower() + assert "document inputs" in content.lower() + + +def test_warns_when_stripping_attachment_blocks(deepseek_provider, caplog): + """A warning is emitted when image/document blocks are dropped so users notice.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "look"}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc", + }, + }, + ], + }, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Screenshot", + "input": {}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "t1", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "abc", + }, + }, + ], + } + ], + }, + ], + } + ) + + with caplog.at_level(logging.WARNING): + deepseek_provider._build_request_body(request) + + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any("stripped unsupported attachment blocks" in r.message for r in warnings) + + +def test_no_warning_when_no_attachments(deepseek_provider, caplog): + """No warning is emitted on plain text-only requests.""" + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "hello"}], + } + ) + + with caplog.at_level(logging.WARNING): + deepseek_provider._build_request_body(request) + + assert not any( + "stripped unsupported attachment blocks" in r.message + for r in caplog.records + if r.levelno == logging.WARNING + ) diff --git a/tests/providers/test_execution_failure_boundary.py b/tests/providers/test_execution_failure_boundary.py new file mode 100644 index 0000000..dcce33f --- /dev/null +++ b/tests/providers/test_execution_failure_boundary.py @@ -0,0 +1,264 @@ +"""Provider streams raise canonical failures after closing committed blocks.""" + +from collections import deque +from collections.abc import AsyncIterator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.nim import NimSettings +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.core.async_iterators import AsyncCloseable +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.http import close_provider_stream +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +class _FailingStream: + def __init__( + self, + chunks: list[object], + error: Exception | None, + *, + close_error: Exception | None = None, + ) -> None: + self._chunks = chunks + self._error = error + self._close_error = close_error + self.close_calls = 0 + + def __aiter__(self) -> AsyncIterator[object]: + return self._iterate() + + async def _iterate(self) -> AsyncIterator[object]: + for chunk in self._chunks: + yield chunk + if self._error is not None: + raise self._error + + async def aclose(self) -> None: + self.close_calls += 1 + if self._close_error is not None: + raise self._close_error + + +def _chunk(*, content: str | None = None, finish_reason: str | None = None) -> object: + delta = MagicMock(content=content, tool_calls=None, reasoning_content=None) + choice = MagicMock(delta=delta, finish_reason=finish_reason) + return MagicMock(choices=[choice], usage=None) + + +def _provider() -> NvidiaNimProvider: + return NvidiaNimProvider( + ProviderConfig( + api_key="test_key", + base_url="https://test.api.nvidia.com/v1", + rate_limit=10, + rate_window=60, + ), + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + + +@pytest.mark.asyncio +async def test_committed_provider_failure_closes_block_then_raises_canonical_value() -> ( + None +): + provider = _provider() + request = make_messages_request( + "test-model", + messages=[], + max_tokens=32, + ) + # Crossing the recovery buffer's byte threshold makes the content block + # downstream-visible before the failure, so its close prelude must escape. + stream = _FailingStream( + [_chunk(content="x" * 65_536)], + RuntimeError("connection lost after commit"), + ) + emitted: deque[str] = deque() + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream, + ), + pytest.raises(ExecutionFailure) as exc_info, + ): + async for event in provider.stream_response( + request, + request_id="req_committed_failure", + ): + emitted.append(event) + + events = parse_sse_text("".join(emitted)) + assert [event.event for event in events][-1] == "content_block_stop" + assert not any(event.event in {"error", "message_stop"} for event in events) + assert exc_info.value.kind is FailureKind.UPSTREAM + assert exc_info.value.status_code == 502 + assert exc_info.value.retryable is False + assert "connection lost after commit" in exc_info.value.message + assert "Request ID: req_committed_failure" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_openai_stream_close_failure_cannot_mask_execution_failure() -> None: + provider = _provider() + request = make_messages_request( + "test-model", + messages=[], + max_tokens=32, + ) + stream = _FailingStream( + [], + RuntimeError("original provider failure"), + close_error=RuntimeError("cleanup api_key=SECRET"), + ) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream, + ), + patch("free_claude_code.providers.http.trace_event") as trace_event, + pytest.raises(ExecutionFailure) as exc_info, + ): + [ + event + async for event in provider.stream_response( + request, + request_id="req_close_failure", + ) + ] + + assert stream.close_calls == 1 + assert exc_info.value.status_code == 502 + assert "original provider failure" in exc_info.value.message + assert "cleanup" not in exc_info.value.message + assert "SECRET" not in exc_info.value.message + trace_event.assert_called_once_with( + stage="provider", + event="provider.stream.close_failed", + source="provider", + provider="NIM", + request_id="req_close_failure", + close_exc_type="RuntimeError", + preserved_exc_type="ExecutionFailure", + ) + assert "SECRET" not in repr(trace_event.call_args) + + +@pytest.mark.asyncio +async def test_stream_close_failure_without_active_error_is_observability_only() -> ( + None +): + stream = _FailingStream( + [], + RuntimeError("unused"), + close_error=RuntimeError("normal close failed"), + ) + + with patch("free_claude_code.providers.http.trace_event") as trace_event: + await close_provider_stream( + stream, + active_error=None, + provider_name="TEST", + request_id="req_normal_close", + ) + + assert stream.close_calls == 1 + trace_event.assert_called_once_with( + stage="provider", + event="provider.stream.close_failed", + source="provider", + provider="TEST", + request_id="req_normal_close", + close_exc_type="RuntimeError", + preserved_exc_type=None, + ) + + +@pytest.mark.asyncio +async def test_completed_stream_close_failure_preserves_success_lifecycle() -> None: + provider = _provider() + request = make_messages_request( + "test-model", + messages=[], + max_tokens=32, + ) + stream = _FailingStream( + [_chunk(content="complete", finish_reason="stop")], + None, + close_error=RuntimeError("cleanup api_key=SECRET"), + ) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream, + ), + patch("free_claude_code.providers.http.trace_event") as trace_event, + ): + emitted = [ + event + async for event in provider.stream_response( + request, + request_id="req_successful_close_failure", + ) + ] + + events = parse_sse_text("".join(emitted)) + assert events[-1].event == "message_stop" + assert sum(event.event == "message_stop" for event in events) == 1 + assert not any(event.event == "error" for event in events) + assert stream.close_calls == 1 + trace_event.assert_called_once_with( + stage="provider", + event="provider.stream.close_failed", + source="provider", + provider="NIM", + request_id="req_successful_close_failure", + close_exc_type="RuntimeError", + preserved_exc_type=None, + ) + assert "SECRET" not in repr(trace_event.call_args) + + +@pytest.mark.asyncio +async def test_closing_public_openai_stream_closes_raw_stream_once() -> None: + provider = _provider() + request = make_messages_request( + "test-model", + messages=[], + max_tokens=32, + ) + raw_stream = _FailingStream( + [ + _chunk(content="x" * 65_536), + _chunk(content="done", finish_reason="stop"), + ], + None, + ) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=raw_stream, + ): + stream = provider.stream_response(request, request_id="req_early_close") + await anext(stream) + assert isinstance(stream, AsyncCloseable) + await stream.aclose() + + assert raw_stream.close_calls == 1 diff --git a/tests/providers/test_failure_policy.py b/tests/providers/test_failure_policy.py new file mode 100644 index 0000000..10e8d78 --- /dev/null +++ b/tests/providers/test_failure_policy.py @@ -0,0 +1,375 @@ +"""Raw provider failure classification into the canonical neutral model.""" + +from collections.abc import Callable +from dataclasses import dataclass +from unittest.mock import Mock + +import httpx +import openai +import pytest + +from free_claude_code.core.diagnostics import ( + ERROR_DETAIL_DISPLAY_CAP_BYTES, + attach_upstream_error_body, +) +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.providers.failure_policy import classify_provider_failure + + +def _openai_status_error( + error_type: type[openai.APIStatusError], + *, + status_code: int, + message: str, + body: object | None = None, +) -> openai.APIStatusError: + request = httpx.Request("POST", "https://provider.test/v1/chat/completions") + response = httpx.Response(status_code, request=request) + return error_type( + message, + response=response, + body=body or {"error": {"message": message}}, + ) + + +def _statusless_openai_error(message: str, body: object | None) -> openai.APIError: + return openai.APIError( + message, + request=httpx.Request("POST", "https://provider.test/v1/chat/completions"), + body=body, + ) + + +def _http_status_error(status_code: int, message: str) -> httpx.HTTPStatusError: + request = httpx.Request("POST", "https://provider.test/v1/messages") + response = httpx.Response( + status_code, + request=request, + json={"error": {"message": message, "api_key": "SECRET"}}, + ) + return httpx.HTTPStatusError(message, request=request, response=response) + + +@dataclass(frozen=True, slots=True) +class _ClassificationCase: + name: str + error: Callable[[], Exception] + kind: FailureKind + status_code: int + retryable: bool + rate_limit_block_seconds: int | None = None + + +_CASES = ( + _ClassificationCase( + "openai_authentication", + lambda: _openai_status_error( + openai.AuthenticationError, + status_code=401, + message="Unauthorized", + ), + FailureKind.AUTHENTICATION, + 401, + False, + ), + _ClassificationCase( + "openai_rate_limit", + lambda: _openai_status_error( + openai.RateLimitError, + status_code=429, + message="Too many requests", + ), + FailureKind.RATE_LIMIT, + 429, + True, + 60, + ), + _ClassificationCase( + "openai_bad_request", + lambda: _openai_status_error( + openai.BadRequestError, + status_code=400, + message="bad tool shape", + ), + FailureKind.INVALID_REQUEST, + 400, + False, + ), + _ClassificationCase( + "openai_overload_marker", + lambda: _openai_status_error( + openai.InternalServerError, + status_code=500, + message="No capacity available", + ), + FailureKind.OVERLOADED, + 529, + True, + ), + _ClassificationCase( + "openai_generic_503_preserved", + lambda: _openai_status_error( + openai.InternalServerError, + status_code=503, + message="generic server failure", + ), + FailureKind.UPSTREAM, + 503, + True, + ), + _ClassificationCase( + "statusless_openai_rate_limit_body", + lambda: _statusless_openai_error( + "stream embedded error", + {"error": {"message": "too many requests", "code": 429}}, + ), + FailureKind.RATE_LIMIT, + 429, + True, + 60, + ), + _ClassificationCase( + "statusless_openai_overload_body", + lambda: _statusless_openai_error( + "ResourceExhausted: limit reached", + {"error": {"message": "ResourceExhausted: limit reached"}}, + ), + FailureKind.OVERLOADED, + 529, + True, + ), + _ClassificationCase( + "statusless_openai_unknown_is_not_retryable", + lambda: _statusless_openai_error( + "stream embedded error", + {"error": {"message": "unknown provider failure"}}, + ), + FailureKind.UPSTREAM, + 500, + False, + ), + _ClassificationCase( + "http_403_keeps_authentication_quirk", + lambda: _http_status_error(403, "Forbidden"), + FailureKind.AUTHENTICATION, + 401, + False, + ), + _ClassificationCase( + "http_502_keeps_overload_quirk", + lambda: _http_status_error(502, "Bad gateway"), + FailureKind.OVERLOADED, + 529, + True, + ), + _ClassificationCase( + "http_599_preserves_status", + lambda: _http_status_error(599, "Upstream failure"), + FailureKind.UPSTREAM, + 599, + True, + ), + _ClassificationCase( + "http_405_is_not_retryable", + lambda: _http_status_error(405, "Wrong endpoint"), + FailureKind.UPSTREAM, + 405, + False, + ), + _ClassificationCase( + "read_timeout_keeps_pre_start_status", + lambda: httpx.ReadTimeout( + "", + request=httpx.Request("POST", "https://provider.test/v1/messages"), + ), + FailureKind.TIMEOUT, + 502, + True, + ), + _ClassificationCase( + "openai_connection_error_keeps_status", + lambda: openai.APIConnectionError( + request=httpx.Request("POST", "https://provider.test/v1/chat/completions") + ), + FailureKind.UNAVAILABLE, + 500, + True, + ), + _ClassificationCase( + "unknown_exception_keeps_gateway_status", + lambda: RuntimeError("unexpected provider failure"), + FailureKind.UPSTREAM, + 502, + False, + ), +) + + +@pytest.mark.parametrize("case", _CASES, ids=lambda case: case.name) +def test_raw_provider_failure_maps_to_canonical_failure( + case: _ClassificationCase, +) -> None: + mark_rate_limited = Mock() + + failure = classify_provider_failure( + case.error(), + provider_name="TEST_PROVIDER", + read_timeout_s=30.0, + request_id="req_classification", + mark_rate_limited=mark_rate_limited, + ) + + assert isinstance(failure, ExecutionFailure) + assert failure.kind is case.kind + assert failure.status_code == case.status_code + assert failure.retryable is case.retryable + assert failure.message.strip() + assert "Request ID: req_classification" in failure.message + assert "SECRET" not in failure.message + if case.rate_limit_block_seconds is None: + mark_rate_limited.assert_not_called() + else: + mark_rate_limited.assert_called_once_with(case.rate_limit_block_seconds) + + +def test_classification_preserves_useful_body_while_redacting_credentials() -> None: + error = _http_status_error( + 400, + "unsupported model format authorization: Bearer AUTH_SECRET", + ) + + failure = classify_provider_failure( + error, + provider_name="LOCAL", + read_timeout_s=60.0, + request_id="req_body", + mark_rate_limited=Mock(), + ) + + assert failure.kind is FailureKind.INVALID_REQUEST + assert failure.status_code == 400 + assert "Upstream provider LOCAL returned HTTP 400." in failure.message + assert "unsupported model format" in failure.message + assert "Request ID: req_body" in failure.message + assert "AUTH_SECRET" not in failure.message + assert "SECRET" not in failure.message + + +def test_auth_failure_preserves_model_error_body_instead_of_masking_it() -> None: + error = _openai_status_error( + openai.AuthenticationError, + status_code=401, + message="Unauthorized", + body={ + "type": "error", + "error": { + "type": "ModelError", + "message": ("Model qwen3.7-max is not supported for format oa-compat"), + }, + }, + ) + + failure = classify_provider_failure( + error, + provider_name="OPENCODE_GO", + read_timeout_s=60.0, + request_id="req_model", + mark_rate_limited=Mock(), + ) + + assert failure.kind is FailureKind.AUTHENTICATION + assert failure.status_code == 401 + assert "Category: ModelError" in failure.message + assert "Provider authentication failed. Check API key." in failure.message + assert "Model qwen3.7-max is not supported for format oa-compat" in failure.message + assert "Request ID: req_model" in failure.message + + +def test_empty_http_error_body_is_reported_explicitly() -> None: + request = httpx.Request("POST", "https://provider.test/v1/messages") + response = httpx.Response(500, request=request, content=b"") + error = httpx.HTTPStatusError( + "Server Error", + request=request, + response=response, + ) + + failure = classify_provider_failure( + error, + provider_name="EMPTY", + read_timeout_s=30.0, + request_id="req_empty", + mark_rate_limited=Mock(), + ) + + assert failure.kind is FailureKind.UPSTREAM + assert failure.status_code == 500 + assert "Upstream provider EMPTY returned HTTP 500." in failure.message + assert "(empty upstream error body)" in failure.message + + +def test_http_405_diagnostic_names_rejected_upstream_endpoint() -> None: + failure = classify_provider_failure( + _http_status_error(405, "Method Not Allowed"), + provider_name="LOCAL", + read_timeout_s=30.0, + request_id="req_405", + mark_rate_limited=Mock(), + ) + + assert failure.kind is FailureKind.UPSTREAM + assert failure.status_code == 405 + assert ( + "Upstream provider LOCAL rejected the request method or endpoint (HTTP 405)." + in failure.message + ) + assert "Request ID: req_405" in failure.message + + +def test_connection_cause_chain_is_redacted_and_capped() -> None: + request = httpx.Request("POST", "https://provider.test/v1/chat/completions") + error = openai.APIConnectionError(request=request) + error.__cause__ = httpx.ConnectError( + "connect failed authorization: Bearer CAUSE_SECRET " + + "x" * (ERROR_DETAIL_DISPLAY_CAP_BYTES + 10), + request=request, + ) + + failure = classify_provider_failure( + error, + provider_name="NIM", + read_timeout_s=30.0, + request_id="req_cause", + mark_rate_limited=Mock(), + ) + + assert "Caused by:" in failure.message + assert "ConnectError: connect failed authorization: " in failure.message + assert "CAUSE_SECRET" not in failure.message + assert f"truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes" in failure.message + assert "Request ID: req_cause" in failure.message + + +def test_attached_streamed_error_body_remains_bounded() -> None: + request = httpx.Request("POST", "https://provider.test/v1/messages") + response = httpx.Response(500, request=request, content=b"") + error = httpx.HTTPStatusError( + "Server Error", + request=request, + response=response, + ) + attach_upstream_error_body( + error, + "x" * (ERROR_DETAIL_DISPLAY_CAP_BYTES + 10), + ) + + failure = classify_provider_failure( + error, + provider_name="LONG", + read_timeout_s=30.0, + request_id="req_long", + mark_rate_limited=Mock(), + ) + + assert f"truncated after {ERROR_DETAIL_DISPLAY_CAP_BYTES} bytes" in failure.message + assert "x" * 100 in failure.message diff --git a/tests/providers/test_fireworks.py b/tests/providers/test_fireworks.py new file mode 100644 index 0000000..fb1deef --- /dev/null +++ b/tests/providers/test_fireworks.py @@ -0,0 +1,133 @@ +"""Tests for the Fireworks AI OpenAI-chat provider.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.config.provider_catalog import FIREWORKS_DEFAULT_BASE +from free_claude_code.core.anthropic.models import Message, MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +@pytest.fixture +def fireworks_provider(): + return profiled_provider( + "fireworks", + ProviderConfig( + api_key="test_fireworks_key", + base_url=FIREWORKS_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ), + rate_limiter=passthrough_rate_limiter(), + ) + + +def test_init_uses_openai_chat_provider(fireworks_provider): + assert isinstance(fireworks_provider, OpenAIChatProvider) + assert fireworks_provider._api_key == "test_fireworks_key" + assert fireworks_provider._base_url == FIREWORKS_DEFAULT_BASE + + +def test_base_url_constant(): + assert FIREWORKS_DEFAULT_BASE == "https://api.fireworks.ai/inference/v1" + + +def test_build_request_body_openai_chat_shape(fireworks_provider): + request = MessagesRequest( + model="accounts/fireworks/models/glm-5p1", + max_tokens=100, + messages=[Message(role="user", content="Hello")], + system="System prompt", + ) + + body = fireworks_provider._build_request_body(request) + + assert body["model"] == "accounts/fireworks/models/glm-5p1" + assert body["max_tokens"] == 100 + assert body["messages"] == [ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "Hello"}, + ] + + +def test_build_request_body_default_max_tokens(fireworks_provider): + request = MessagesRequest( + model="m", + messages=[Message(role="user", content="x")], + ) + + body = fireworks_provider._build_request_body(request) + + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + +def test_build_request_body_global_disable_blocks_thinking(): + provider = profiled_provider( + "fireworks", + ProviderConfig( + api_key="k", + base_url=FIREWORKS_DEFAULT_BASE, + rate_limit=1, + rate_window=1, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [{"type": "thinking", "thinking": "hidden"}], + } + ], + } + ) + + body = provider._build_request_body(request) + + assert "reasoning_content" not in body["messages"][0] + + +def test_build_request_body_preserves_validated_extra_body(fireworks_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "extra_body": {"custom_param": "value"}, + } + ) + + body = fireworks_provider._build_request_body(request) + + assert body["extra_body"] == {"custom_param": "value"} + + +def test_build_request_body_rejects_reserved_extra_body_keys(fireworks_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "extra_body": {"temperature": 0.1}, + } + ) + + with pytest.raises(InvalidRequestError, match="extra_body must not override"): + fireworks_provider._build_request_body(request) + + +@pytest.mark.asyncio +async def test_cleanup_closes_openai_client(fireworks_provider): + fireworks_provider._client = MagicMock() + fireworks_provider._client.close = AsyncMock() + + await fireworks_provider.cleanup() + + fireworks_provider._client.close.assert_awaited_once() diff --git a/tests/providers/test_gemini.py b/tests/providers/test_gemini.py new file mode 100644 index 0000000..5315f18 --- /dev/null +++ b/tests/providers/test_gemini.py @@ -0,0 +1,430 @@ +"""Tests for Google AI Studio Gemini (OpenAI-compatible) provider.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import GEMINI_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.gemini import GeminiProvider +from free_claude_code.providers.gemini.quirks import ( + GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR, +) +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +def make_request(**overrides): + return make_messages_request("models/gemini-3.1-flash-lite", **overrides) + + +def _simulate_openai_sdk_wire_json(body: dict) -> dict: + wire = {key: value for key, value in body.items() if key != "extra_body"} + sdk_extra = body.get("extra_body") + if isinstance(sdk_extra, dict): + wire.update(sdk_extra) + return wire + + +@pytest.fixture +def gemini_config(): + return ProviderConfig( + api_key="test_gemini_key", + base_url=GEMINI_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def gemini_provider(gemini_config): + return GeminiProvider(gemini_config, rate_limiter=passthrough_rate_limiter()) + + +def test_init(gemini_config): + """Test provider initialization.""" + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = GeminiProvider( + gemini_config, rate_limiter=passthrough_rate_limiter() + ) + assert provider._api_key == "test_gemini_key" + assert ( + provider._base_url + == "https://generativelanguage.googleapis.com/v1beta/openai" + ) + mock_openai.assert_called_once() + + +def test_default_base_url_constant(): + assert GEMINI_DEFAULT_BASE == ( + "https://generativelanguage.googleapis.com/v1beta/openai/" + ) + + +def test_build_request_body_basic(gemini_provider): + """Basic body conversion attaches Gemini thinking fields when thinking is on.""" + req = make_request() + body = gemini_provider._build_request_body(req) + + assert body["model"] == "models/gemini-3.1-flash-lite" + assert body["messages"][0]["role"] == "system" + assert "reasoning_effort" not in body + eb = body.get("extra_body") + assert isinstance(eb, dict) + literal_extra_body = eb.get("extra_body") + assert isinstance(literal_extra_body, dict) + gc = literal_extra_body.get("google") + assert isinstance(gc, dict) + tc = gc.get("thinking_config") + assert isinstance(tc, dict) + assert tc.get("include_thoughts") is True + assert "google" not in eb + + +def test_build_request_body_sdk_wire_json_has_literal_extra_body(gemini_provider): + """Regression for issue #542: SDK merge must not send top-level google.""" + req = make_request() + + body = gemini_provider._build_request_body(req) + wire_json = _simulate_openai_sdk_wire_json(body) + + assert "reasoning_effort" not in wire_json + assert "google" not in wire_json + literal_extra_body = wire_json.get("extra_body") + assert isinstance(literal_extra_body, dict) + google = literal_extra_body.get("google") + assert isinstance(google, dict) + thinking_config = google.get("thinking_config") + assert isinstance(thinking_config, dict) + assert thinking_config.get("include_thoughts") is True + + +def test_build_request_body_global_disable_sets_reasoning_none(): + """When thinking is off, Gemini uses reasoning_effort none (Gemini 2.5 convention).""" + provider = GeminiProvider( + ProviderConfig( + api_key="test_gemini_key", + base_url=GEMINI_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + body = provider._build_request_body(req) + + assert body["reasoning_effort"] == "none" + roles = [m.get("role") for m in body.get("messages", [])] + assert "assistant_reasoning_content" not in roles + + +def test_build_request_body_preserves_caller_extra_body(gemini_provider): + req = make_request(extra_body={"metadata": {"user": "u1"}}) + + body = gemini_provider._build_request_body(req) + + assert "reasoning_effort" not in body + eb = body.get("extra_body") + assert isinstance(eb, dict) + assert eb.get("metadata") == {"user": "u1"} + literal_extra_body = eb.get("extra_body") + assert isinstance(literal_extra_body, dict) + google = literal_extra_body.get("google") + assert isinstance(google, dict) + + +def test_build_request_body_merges_caller_nested_google(gemini_provider): + req = make_request( + extra_body={ + "metadata": {"user": "u1"}, + "extra_body": { + "google": { + "thinking_config": {"budget_tokens": 128}, + "cached_content": "cachedContents/example", + } + }, + } + ) + + body = gemini_provider._build_request_body(req) + + assert "reasoning_effort" not in body + eb = body.get("extra_body") + assert isinstance(eb, dict) + assert eb.get("metadata") == {"user": "u1"} + literal_extra_body = eb.get("extra_body") + assert isinstance(literal_extra_body, dict) + google = literal_extra_body.get("google") + assert isinstance(google, dict) + assert google.get("cached_content") == "cachedContents/example" + thinking_config = google.get("thinking_config") + assert isinstance(thinking_config, dict) + assert thinking_config.get("budget_tokens") == 128 + assert thinking_config.get("include_thoughts") is True + + +def test_build_request_body_preserves_tool_call_extra_content(gemini_provider): + req = make_request( + system=None, + messages=[ + {"role": "user", "content": "Find files"}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "function-call-1", + "name": "Glob", + "input": {"pattern": "*.py"}, + "extra_content": { + "google": {"thought_signature": "sig-from-client"} + }, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "function-call-1", + "content": "[]", + }, + ], + }, + ], + ) + + body = gemini_provider._build_request_body(req) + + tool_call = body["messages"][1]["tool_calls"][0] + assert tool_call["extra_content"] == { + "google": {"thought_signature": "sig-from-client"} + } + + +def test_build_request_body_uses_cached_tool_call_signature(gemini_provider): + gemini_provider._record_tool_call_extra_content( + "function-call-1", {"google": {"thought_signature": "sig-from-cache"}} + ) + req = make_request( + system=None, + messages=[ + {"role": "user", "content": "Find files"}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "function-call-1", + "name": "Glob", + "input": {"pattern": "*.py"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "function-call-1", + "content": "[]", + }, + ], + }, + ], + ) + + body = gemini_provider._build_request_body(req) + + tool_call = body["messages"][1]["tool_calls"][0] + assert tool_call["extra_content"] == { + "google": {"thought_signature": "sig-from-cache"} + } + + +def test_build_request_body_adds_gemini3_current_turn_fallback_signature( + gemini_provider, +): + req = make_request( + system=None, + messages=[ + {"role": "user", "content": "Find files"}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "function-call-1", + "name": "Glob", + "input": {"pattern": "*.py"}, + }, + { + "type": "tool_use", + "id": "function-call-2", + "name": "Read", + "input": {"file_path": "a.py"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "function-call-1", + "content": "[]", + }, + { + "type": "tool_result", + "tool_use_id": "function-call-2", + "content": "contents", + }, + ], + }, + ], + ) + + body = gemini_provider._build_request_body(req) + + tool_calls = body["messages"][1]["tool_calls"] + assert tool_calls[0]["extra_content"] == { + "google": {"thought_signature": GEMINI_SKIP_THOUGHT_SIGNATURE_VALIDATOR} + } + assert "extra_content" not in tool_calls[1] + + +@pytest.mark.asyncio +async def test_stream_response_text(gemini_provider): + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello back!", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + gemini_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in gemini_provider.stream_response(req)] + + assert any( + '"text_delta"' in event and "Hello back!" in event for event in events + ) + kwargs = mock_create.call_args.kwargs + assert "reasoning_effort" not in kwargs + extra_body = kwargs.get("extra_body") + assert isinstance(extra_body, dict) + literal_extra_body = extra_body.get("extra_body") + assert isinstance(literal_extra_body, dict) + google = literal_extra_body.get("google") + assert isinstance(google, dict) + thinking_config = google.get("thinking_config") + assert isinstance(thinking_config, dict) + assert thinking_config.get("include_thoughts") is True + + +@pytest.mark.asyncio +async def test_stream_response_preserves_tool_call_extra_content(gemini_provider): + req = make_request() + + mock_tc = MagicMock() + mock_tc.index = 0 + mock_tc.id = "function-call-1" + mock_tc.extra_content = {"google": {"thought_signature": "sig-stream"}} + mock_tc.function = MagicMock() + mock_tc.function.name = "Glob" + mock_tc.function.arguments = '{"pattern":"*.py"}' + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content=None, + tool_calls=[mock_tc], + ), + finish_reason="tool_calls", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + gemini_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in gemini_provider.stream_response(req)] + + tool_starts = [ + event + for event in events + if '"content_block_start"' in event and '"tool_use"' in event + ] + assert any( + '"extra_content"' in event and "sig-stream" in event for event in tool_starts + ) + assert gemini_provider._tool_call_extra_content_by_id["function-call-1"] == { + "google": {"thought_signature": "sig-stream"} + } + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(gemini_provider): + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking...", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + gemini_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in gemini_provider.stream_response(req)] + + assert any( + '"thinking_delta"' in event and "Thinking..." in event for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(gemini_provider): + gemini_provider._client = AsyncMock() + + await gemini_provider.cleanup() + + gemini_provider._client.close.assert_called_once() diff --git a/tests/providers/test_github_models.py b/tests/providers/test_github_models.py new file mode 100644 index 0000000..ec40751 --- /dev/null +++ b/tests/providers/test_github_models.py @@ -0,0 +1,321 @@ +"""Tests for GitHub Models OpenAI-compatible provider.""" + +from collections.abc import AsyncIterator +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from free_claude_code.config.provider_catalog import GITHUB_MODELS_DEFAULT_BASE +from free_claude_code.core.anthropic.models import Message, MessagesRequest +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.github_models import GitHubModelsProvider +from free_claude_code.providers.github_models.client import GITHUB_MODELS_CATALOG_URL +from free_claude_code.providers.model_listing import ModelListResponseError +from tests.providers.support import passthrough_rate_limiter + + +@pytest.fixture +def github_models_config() -> ProviderConfig: + return ProviderConfig( + api_key="test-github-models-token", + base_url=GITHUB_MODELS_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def github_models_provider( + github_models_config: ProviderConfig, +) -> GitHubModelsProvider: + return GitHubModelsProvider( + github_models_config, rate_limiter=passthrough_rate_limiter() + ) + + +def _request(model: str = "openai/gpt-4.1") -> MessagesRequest: + return MessagesRequest( + model=model, + max_tokens=100, + messages=[Message(role="user", content="hi")], + ) + + +def _chunk(delta: SimpleNamespace, *, finish_reason: str = "stop") -> SimpleNamespace: + return SimpleNamespace( + choices=[SimpleNamespace(delta=delta, finish_reason=finish_reason)], + usage=SimpleNamespace(completion_tokens=5, prompt_tokens=8), + ) + + +async def _stream(*chunks: SimpleNamespace) -> AsyncIterator[SimpleNamespace]: + for chunk in chunks: + yield chunk + + +def _catalog_response(payload: object) -> httpx.Response: + return httpx.Response( + 200, + json=payload, + request=httpx.Request("GET", GITHUB_MODELS_CATALOG_URL), + ) + + +def test_default_base_url_constant() -> None: + assert GITHUB_MODELS_DEFAULT_BASE == "https://models.github.ai/inference" + + +def test_init_uses_default_base_url_api_key_and_github_headers( + github_models_config: ProviderConfig, +) -> None: + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = GitHubModelsProvider( + github_models_config, rate_limiter=passthrough_rate_limiter() + ) + + assert provider._api_key == "test-github-models-token" + assert provider._base_url == GITHUB_MODELS_DEFAULT_BASE + assert provider._catalog_url == GITHUB_MODELS_CATALOG_URL + assert mock_openai.call_args.kwargs["base_url"] == GITHUB_MODELS_DEFAULT_BASE + assert mock_openai.call_args.kwargs["api_key"] == "test-github-models-token" + assert mock_openai.call_args.kwargs["default_headers"] == { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2026-03-10", + } + + +def test_init_strips_trailing_slash(github_models_config: ProviderConfig) -> None: + config = replace( + github_models_config, + base_url=f"{GITHUB_MODELS_DEFAULT_BASE}/", + ) + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = GitHubModelsProvider(config, rate_limiter=passthrough_rate_limiter()) + + assert provider._base_url == GITHUB_MODELS_DEFAULT_BASE + + +def test_model_list_headers_use_bearer_auth( + github_models_provider: GitHubModelsProvider, +) -> None: + assert github_models_provider._model_list_headers() == { + "Accept": "application/vnd.github+json", + "Authorization": "Bearer test-github-models-token", + "X-GitHub-Api-Version": "2026-03-10", + } + + +def test_build_request_body_uses_shared_openai_chat_policy( + github_models_provider: GitHubModelsProvider, +) -> None: + request = _request() + + body = github_models_provider._build_request_body(request, thinking_enabled=True) + + assert body["model"] == "openai/gpt-4.1" + assert body["max_tokens"] == 100 + assert "extra_body" not in body + + +@pytest.mark.asyncio +async def test_lists_stream_tool_capable_models_only( + github_models_provider: GitHubModelsProvider, +) -> None: + with patch.object( + github_models_provider._model_list_client, + "get", + new_callable=AsyncMock, + return_value=_catalog_response( + [ + { + "id": "openai/gpt-4.1", + "capabilities": ["streaming", "tool-calling"], + }, + { + "id": "openai/text-only", + "capabilities": ["streaming"], + }, + { + "id": "openai/no-stream-tools", + "capabilities": ["tool-calling"], + }, + ] + ), + ) as mock_get: + assert await github_models_provider.list_model_ids() == frozenset( + {"openai/gpt-4.1"} + ) + + mock_get.assert_awaited_once_with( + GITHUB_MODELS_CATALOG_URL, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": "Bearer test-github-models-token", + "X-GitHub-Api-Version": "2026-03-10", + }, + ) + + +@pytest.mark.asyncio +async def test_model_list_rejects_malformed_payload( + github_models_provider: GitHubModelsProvider, +) -> None: + with ( + patch.object( + github_models_provider._model_list_client, + "get", + new_callable=AsyncMock, + return_value=_catalog_response({"data": []}), + ), + pytest.raises(ModelListResponseError, match="top-level array"), + ): + await github_models_provider.list_model_ids() + + +@pytest.mark.asyncio +async def test_model_list_returns_empty_set_when_no_models_support_streaming_tools( + github_models_provider: GitHubModelsProvider, +) -> None: + with patch.object( + github_models_provider._model_list_client, + "get", + new_callable=AsyncMock, + return_value=_catalog_response( + [ + {"id": "openai/text-only", "capabilities": ["streaming"]}, + {"id": "openai/non-stream-tool", "capabilities": ["tool-calling"]}, + ] + ), + ): + assert await github_models_provider.list_model_ids() == frozenset() + + +@pytest.mark.asyncio +async def test_stream_response_text( + github_models_provider: GitHubModelsProvider, +) -> None: + delta = SimpleNamespace( + content="Hello from GitHub Models", + reasoning_content=None, + tool_calls=None, + ) + + with patch.object( + github_models_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=_stream(_chunk(delta)), + ) as mock_create: + events = [ + event async for event in github_models_provider.stream_response(_request()) + ] + + parsed = parse_sse_text("".join(events)) + assert any( + event.event == "content_block_delta" + and event.data.get("delta", {}).get("text") == "Hello from GitHub Models" + for event in parsed + ) + assert mock_create.call_args.kwargs["model"] == "openai/gpt-4.1" + assert mock_create.call_args.kwargs["stream"] is True + + +@pytest.mark.asyncio +async def test_stream_response_tool_call( + github_models_provider: GitHubModelsProvider, +) -> None: + tool_call = SimpleNamespace( + index=0, + id="call_1", + function=SimpleNamespace(name="echo", arguments='{"value":"x"}'), + ) + delta = SimpleNamespace( + content=None, reasoning_content=None, tool_calls=[tool_call] + ) + request = MessagesRequest.model_validate( + { + "model": "openai/gpt-4.1", + "messages": [{"role": "user", "content": "Use the tool"}], + "tools": [ + { + "name": "echo", + "description": "Echo a value", + "input_schema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + } + ], + } + ) + + with patch.object( + github_models_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=_stream(_chunk(delta, finish_reason="tool_calls")), + ): + events = [ + event async for event in github_models_provider.stream_response(request) + ] + + parsed = parse_sse_text("".join(events)) + assert any( + event.event == "content_block_start" + and event.data.get("content_block", {}).get("type") == "tool_use" + and event.data.get("content_block", {}).get("name") == "echo" + for event in parsed + ) + assert any( + event.event == "content_block_delta" + and event.data.get("delta", {}).get("partial_json") == '{"value":"x"}' + for event in parsed + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content( + github_models_provider: GitHubModelsProvider, +) -> None: + delta = SimpleNamespace( + content=None, + reasoning_content="Thinking via GitHub Models", + tool_calls=None, + ) + + with patch.object( + github_models_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=_stream(_chunk(delta)), + ): + events = [ + event async for event in github_models_provider.stream_response(_request()) + ] + + parsed = parse_sse_text("".join(events)) + assert any( + event.event == "content_block_delta" + and event.data.get("delta", {}).get("thinking") == "Thinking via GitHub Models" + for event in parsed + ) + + +@pytest.mark.asyncio +async def test_cleanup(github_models_provider: GitHubModelsProvider) -> None: + github_models_provider._client = AsyncMock() + github_models_provider._model_list_client = AsyncMock() + + await github_models_provider.cleanup() + + github_models_provider._client.close.assert_called_once() + github_models_provider._model_list_client.aclose.assert_called_once() diff --git a/tests/providers/test_groq.py b/tests/providers/test_groq.py new file mode 100644 index 0000000..5960ae3 --- /dev/null +++ b/tests/providers/test_groq.py @@ -0,0 +1,212 @@ +"""Tests for Groq (OpenAI-compatible) provider.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import GROQ_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def make_request(**overrides): + return make_messages_request("llama-3.3-70b-versatile", **overrides) + + +@pytest.fixture +def groq_config(): + return ProviderConfig( + api_key="test_groq_key", + base_url=GROQ_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def groq_provider(groq_config): + return profiled_provider( + "groq", groq_config, rate_limiter=passthrough_rate_limiter() + ) + + +def test_init(groq_config): + """Test provider initialization.""" + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = profiled_provider( + "groq", groq_config, rate_limiter=passthrough_rate_limiter() + ) + assert provider._api_key == "test_groq_key" + assert provider._base_url == GROQ_DEFAULT_BASE + mock_openai.assert_called_once() + + +def test_default_base_url_constant(): + assert GROQ_DEFAULT_BASE == "https://api.groq.com/openai/v1" + + +def test_build_request_body_basic(groq_provider): + """Basic request body conversion attaches system message from Claude request.""" + req = make_request() + body = groq_provider._build_request_body(req) + + assert body["model"] == "llama-3.3-70b-versatile" + assert body["messages"][0]["role"] == "system" + assert "max_completion_tokens" in body + + +def test_build_request_body_global_disable_blocks_reasoning_mapping(): + provider = profiled_provider( + "groq", + ProviderConfig( + api_key="test_groq_key", + base_url=GROQ_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + body = provider._build_request_body(req) + + roles = [m.get("role") for m in body.get("messages", [])] + assert "assistant_reasoning_content" not in roles + + +def test_build_request_body_sanitizes_and_remaps_via_mock_converter(groq_provider): + with patch( + "free_claude_code.providers.openai_chat.request_policy.build_base_request_body" + ) as mock_convert: + mock_convert.return_value = { + "model": "llama-3.3-70b-versatile", + "messages": [ + {"role": "user", "name": "bad", "content": "hello"}, + { + "role": "assistant", + "tool_calls": [], + "name": "nope", + "content": "ok", + }, + ], + "logprobs": True, + "logit_bias": {"1": -100}, + "top_logprobs": 2, + "max_tokens": 42, + "n": 4, + } + req = make_request() + body = groq_provider._build_request_body(req) + + msgs = body["messages"] + assert msgs[0].get("name") is None and msgs[1].get("name") is None + for key in ("logprobs", "logit_bias", "top_logprobs"): + assert key not in body + assert body.get("max_tokens") is None + assert body["max_completion_tokens"] == 42 + assert body["n"] == 1 + + +def test_build_request_body_prefers_existing_max_completion_tokens(groq_provider): + with patch( + "free_claude_code.providers.openai_chat.request_policy.build_base_request_body" + ) as mock_convert: + mock_convert.return_value = { + "model": "llama-3.3-70b-versatile", + "messages": [{"role": "user", "content": "x"}], + "max_completion_tokens": 77, + "max_tokens": 999, + } + body = groq_provider._build_request_body(make_request()) + + assert body["max_completion_tokens"] == 77 + assert "max_tokens" not in body + + +def test_build_request_body_preserves_caller_extra_body(groq_provider): + req = make_request(extra_body={"metadata": {"user": "u1"}}) + + body = groq_provider._build_request_body(req) + + eb = body.get("extra_body") + assert isinstance(eb, dict) + assert eb.get("metadata") == {"user": "u1"} + + +@pytest.mark.asyncio +async def test_stream_response_text(groq_provider): + """Text content deltas are emitted as text blocks.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello back!", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + groq_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in groq_provider.stream_response(req)] + + assert any( + '"text_delta"' in event and "Hello back!" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(groq_provider): + """reasoning_content deltas are emitted as thinking blocks.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking...", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + groq_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in groq_provider.stream_response(req)] + + assert any( + '"thinking_delta"' in event and "Thinking..." in event for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(groq_provider): + groq_provider._client = AsyncMock() + + await groq_provider.cleanup() + + groq_provider._client.close.assert_called_once() diff --git a/tests/providers/test_huggingface.py b/tests/providers/test_huggingface.py new file mode 100644 index 0000000..60fa69a --- /dev/null +++ b/tests/providers/test_huggingface.py @@ -0,0 +1,215 @@ +"""Tests for Hugging Face Inference Providers.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import HUGGINGFACE_DEFAULT_BASE +from free_claude_code.core.anthropic import ReasoningReplayMode +from free_claude_code.providers.base import ProviderConfig +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def make_request(**overrides): + return make_messages_request("openai/gpt-oss-120b:fastest", **overrides) + + +@pytest.fixture +def huggingface_config(): + return ProviderConfig( + api_key="test_hf_key", + base_url=HUGGINGFACE_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def huggingface_provider(huggingface_config): + return profiled_provider( + "huggingface", huggingface_config, rate_limiter=passthrough_rate_limiter() + ) + + +def test_default_base_url_constant(): + assert HUGGINGFACE_DEFAULT_BASE == "https://router.huggingface.co/v1" + + +def test_init_uses_default_base_url_and_api_key(huggingface_config): + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = profiled_provider( + "huggingface", huggingface_config, rate_limiter=passthrough_rate_limiter() + ) + + assert provider._api_key == "test_hf_key" + assert provider._base_url == HUGGINGFACE_DEFAULT_BASE + mock_openai.assert_called_once() + + +def test_init_strips_trailing_slash(huggingface_config): + config = replace(huggingface_config, base_url=f"{HUGGINGFACE_DEFAULT_BASE}/") + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = profiled_provider( + "huggingface", config, rate_limiter=passthrough_rate_limiter() + ) + + assert provider._base_url == HUGGINGFACE_DEFAULT_BASE + + +def test_build_request_body_keeps_max_tokens(huggingface_provider): + with patch( + "free_claude_code.providers.openai_chat.request_policy.build_base_request_body" + ) as mock_convert: + mock_convert.return_value = { + "model": "openai/gpt-oss-120b:fastest", + "messages": [{"role": "user", "name": "alice", "content": "hi"}], + "max_tokens": 42, + } + + body = huggingface_provider._build_request_body(make_request()) + + mock_convert.assert_called_once() + assert ( + mock_convert.call_args.kwargs["reasoning_replay"] + is ReasoningReplayMode.DISABLED + ) + assert body["messages"][0].get("name") == "alice" + assert body["max_tokens"] == 42 + assert "max_completion_tokens" not in body + + +def test_build_request_body_preserves_caller_extra_body(huggingface_provider): + extra_body = {"provider": "auto", "routing": {"bill_to": "my-org"}} + req = make_request(extra_body=extra_body) + + body = huggingface_provider._build_request_body(req) + + assert body["extra_body"] == extra_body + assert body["extra_body"] is not extra_body + assert body["extra_body"]["routing"] is not extra_body["routing"] + + +def test_build_request_body_does_not_replay_prior_thinking_blocks( + huggingface_provider, +): + req = make_request( + system=None, + messages=[ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "hidden prior thought"}, + {"type": "text", "text": "visible answer"}, + ], + } + ], + ) + + body = huggingface_provider._build_request_body(req) + + assert body["messages"] == [{"role": "assistant", "content": "visible answer"}] + assert "reasoning_content" not in body["messages"][0] + assert "hidden prior thought" not in str(body) + + +def test_build_request_body_does_not_replay_top_level_reasoning_content( + huggingface_provider, +): + req = make_request( + system=None, + messages=[ + { + "role": "assistant", + "content": "visible answer", + "reasoning_content": "hidden prior reasoning", + } + ], + ) + + body = huggingface_provider._build_request_body(req) + + assert body["messages"] == [{"role": "assistant", "content": "visible answer"}] + assert "hidden prior reasoning" not in str(body) + + +@pytest.mark.asyncio +async def test_stream_response_text(huggingface_provider): + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello from Hugging Face", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + huggingface_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event + async for event in huggingface_provider.stream_response(make_request()) + ] + + assert any( + '"text_delta"' in event and "Hello from Hugging Face" in event + for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(huggingface_provider): + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking via router", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + huggingface_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event + async for event in huggingface_provider.stream_response(make_request()) + ] + + assert any( + '"thinking_delta"' in event and "Thinking via router" in event + for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(huggingface_provider): + huggingface_provider._client = AsyncMock() + + await huggingface_provider.cleanup() + + huggingface_provider._client.close.assert_called_once() diff --git a/tests/providers/test_kimi.py b/tests/providers/test_kimi.py new file mode 100644 index 0000000..e8e6c40 --- /dev/null +++ b/tests/providers/test_kimi.py @@ -0,0 +1,109 @@ +"""Tests for the Kimi OpenAI-chat provider.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.config.provider_catalog import KIMI_DEFAULT_BASE +from free_claude_code.core.anthropic.models import Message, MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +@pytest.fixture +def kimi_provider(): + return profiled_provider( + "kimi", + ProviderConfig( + api_key="test_kimi_key", + base_url=KIMI_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ), + rate_limiter=passthrough_rate_limiter(), + ) + + +def test_init_uses_openai_chat_provider(kimi_provider): + assert isinstance(kimi_provider, OpenAIChatProvider) + assert kimi_provider._api_key == "test_kimi_key" + assert kimi_provider._base_url == "https://api.moonshot.ai/v1" + + +def test_build_request_body_openai_chat(kimi_provider): + request = MessagesRequest( + model="kimi-k2.5", + max_tokens=50, + messages=[Message(role="user", content="hi")], + ) + + body = kimi_provider._build_request_body(request) + + assert body["model"] == "kimi-k2.5" + assert body["max_tokens"] == 50 + assert body["messages"] == [{"role": "user", "content": "hi"}] + assert "extra_body" not in body + + +def test_build_request_body_default_max_tokens(kimi_provider): + request = MessagesRequest( + model="m", + messages=[Message(role="user", content="x")], + ) + + body = kimi_provider._build_request_body(request) + + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + +def test_build_request_body_rejects_caller_extra_body(kimi_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "extra_body": {"x": 1}, + } + ) + + with pytest.raises(InvalidRequestError, match="Kimi Chat Completions"): + kimi_provider._build_request_body(request) + + +def test_build_request_body_disables_kimi_thinking(kimi_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "thinking": {"type": "disabled"}, + } + ) + + body = kimi_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "disabled"} + + +@pytest.mark.asyncio +async def test_model_list_uses_openai_client_models_endpoint(kimi_provider): + kimi_provider._client.models.list = AsyncMock( + return_value=SimpleNamespace(data=[SimpleNamespace(id="kimi-k2.5")]) + ) + + assert await kimi_provider.list_model_ids() == frozenset({"kimi-k2.5"}) + + kimi_provider._client.models.list.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_cleanup_closes_openai_client(kimi_provider): + kimi_provider._client = MagicMock() + kimi_provider._client.close = AsyncMock() + + await kimi_provider.cleanup() + + kimi_provider._client.close.assert_awaited_once() diff --git a/tests/providers/test_llamacpp.py b/tests/providers/test_llamacpp.py new file mode 100644 index 0000000..462a01d --- /dev/null +++ b/tests/providers/test_llamacpp.py @@ -0,0 +1,146 @@ +"""Tests for the llama.cpp OpenAI-compatible provider.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + +LLAMACPP_MODEL = "llamacpp-community/qwen2.5-7b-instruct" + + +@pytest.fixture +def provider() -> OpenAIChatProvider: + return profiled_provider( + "llamacpp", + ProviderConfig(api_key="llamacpp", base_url="http://localhost:8080/v1"), + rate_limiter=passthrough_rate_limiter(), + ) + + +@pytest.mark.parametrize( + ("configured", "expected"), + [ + ("http://localhost:8080", "http://localhost:8080/v1"), + ("http://localhost:8080/", "http://localhost:8080/v1"), + ("http://localhost:8080/v1", "http://localhost:8080/v1"), + ("http://localhost:8080/v1/", "http://localhost:8080/v1"), + ], +) +def test_init_normalizes_openai_base_url(configured: str, expected: str) -> None: + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as openai_client: + provider = profiled_provider( + "llamacpp", + ProviderConfig(api_key="llamacpp", base_url=configured), + rate_limiter=passthrough_rate_limiter(), + ) + + assert provider._base_url == expected + assert openai_client.call_args.kwargs["base_url"] == expected + + +def test_init_uses_openai_chat_client() -> None: + config = ProviderConfig( + api_key="llamacpp", + base_url="http://localhost:8080/v1/", + http_read_timeout=600.0, + http_write_timeout=15.0, + http_connect_timeout=5.0, + ) + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as openai_client: + provider = profiled_provider( + "llamacpp", config, rate_limiter=passthrough_rate_limiter() + ) + + assert provider._provider_name == "LLAMACPP" + assert provider._base_url == "http://localhost:8080/v1" + assert provider._api_key == "llamacpp" + timeout = openai_client.call_args.kwargs["timeout"] + assert (timeout.read, timeout.write, timeout.connect) == (600.0, 15.0, 5.0) + + +def test_build_request_body_uses_openai_chat_shape( + provider: OpenAIChatProvider, +) -> None: + request = make_messages_request(LLAMACPP_MODEL, max_tokens=None) + + body = provider._build_request_body(request) + + assert body["model"] == LLAMACPP_MODEL + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + assert body["messages"][0]["role"] == "system" + assert "thinking" not in body + + +def test_disabled_thinking_does_not_replay_assistant_reasoning( + provider: OpenAIChatProvider, +) -> None: + request = make_messages_request( + LLAMACPP_MODEL, + system=None, + messages=[ + {"role": "user", "content": "Hi"}, + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "private", "signature": "s"}, + {"type": "text", "text": "visible"}, + ], + }, + ], + ) + + body = provider._build_request_body(request, thinking_enabled=False) + + assert "private" not in str(body) + assert "visible" in str(body) + + +@pytest.mark.asyncio +async def test_stream_response_uses_shared_openai_chat_provider( + provider: OpenAIChatProvider, +) -> None: + chunk = MagicMock() + chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello from llama.cpp", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + chunk.usage = MagicMock(prompt_tokens=8, completion_tokens=4) + + async def stream(): + yield chunk + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream(), + ) as create: + output = "".join( + [ + event + async for event in provider.stream_response( + make_messages_request(LLAMACPP_MODEL) + ) + ] + ) + + assert create.call_args.kwargs["stream"] is True + assert create.call_args.kwargs["model"] == LLAMACPP_MODEL + assert "Hello from llama.cpp" in output + assert parse_sse_text(output)[-1].event == "message_stop" diff --git a/tests/providers/test_lmstudio.py b/tests/providers/test_lmstudio.py new file mode 100644 index 0000000..25c447b --- /dev/null +++ b/tests/providers/test_lmstudio.py @@ -0,0 +1,242 @@ +"""Tests for LM Studio (OpenAI-compatible chat completions) provider.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.provider_catalog import LMSTUDIO_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.lmstudio import LMStudioProvider +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +def make_request(**overrides): + return make_messages_request("lmstudio-community/qwen2.5-7b-instruct", **overrides) + + +@pytest.fixture +def lmstudio_config(): + return ProviderConfig( + api_key="lm-studio", + base_url=LMSTUDIO_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + ) + + +@pytest.fixture +def lmstudio_provider(lmstudio_config): + return LMStudioProvider(lmstudio_config, rate_limiter=passthrough_rate_limiter()) + + +def test_init(lmstudio_config): + """Test provider initialization.""" + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = LMStudioProvider( + lmstudio_config, rate_limiter=passthrough_rate_limiter() + ) + assert provider._api_key == "lm-studio" + assert provider._base_url == LMSTUDIO_DEFAULT_BASE + assert provider._provider_name == "LMSTUDIO" + mock_openai.assert_called_once() + + +def test_default_base_url_constant(): + assert LMSTUDIO_DEFAULT_BASE == "http://localhost:1234/v1" + + +def test_build_request_body_basic(lmstudio_provider): + req = make_request() + body = lmstudio_provider._build_request_body(req) + + assert body["model"] == "lmstudio-community/qwen2.5-7b-instruct" + assert body["messages"][0]["role"] == "system" + + +def test_build_request_body_never_replays_prior_thinking(lmstudio_provider): + """Mistral-family templates have no assistant reasoning field; prior-turn + thinking must never be replayed regardless of the enable_thinking setting.""" + req = make_request( + messages=[ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "prior reasoning", + "signature": "s", + } + ], + }, + ] + ) + body = lmstudio_provider._build_request_body(req) + + roles = [m.get("role") for m in body.get("messages", [])] + assert "assistant_reasoning_content" not in roles + assert "prior reasoning" not in str(body) + + +def test_preflight_builds_before_context_budget_and_preserves_false( + lmstudio_provider, +): + request = make_request() + calls: list[tuple[str, object]] = [] + + def build(request_arg, thinking_enabled=None): + assert request_arg is request + calls.append(("build", thinking_enabled)) + return {} + + def check_context(request_arg): + assert request_arg is request + calls.append(("context", request_arg)) + + with ( + patch.object(lmstudio_provider, "_build_request_body", side_effect=build), + patch.object( + lmstudio_provider, + "_preflight_context_budget", + side_effect=check_context, + ), + ): + lmstudio_provider.preflight_stream(request, thinking_enabled=False) + + assert calls == [("build", False), ("context", request)] + + +def test_preflight_conversion_failure_skips_context_budget(lmstudio_provider): + request = make_request() + conversion_error = InvalidRequestError("invalid request conversion") + + with ( + patch.object( + lmstudio_provider, + "_build_request_body", + side_effect=conversion_error, + ), + patch.object(lmstudio_provider, "_preflight_context_budget") as context, + pytest.raises(InvalidRequestError, match="invalid request conversion"), + ): + lmstudio_provider.preflight_stream(request, thinking_enabled=True) + + context.assert_not_called() + + +@pytest.mark.asyncio +async def test_stream_response_text(lmstudio_provider): + """Text content deltas are emitted through the shared OpenAI-chat provider.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello back!", reasoning_content=None, tool_calls=None + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + lmstudio_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in lmstudio_provider.stream_response(req)] + + assert any( + '"text_delta"' in event and "Hello back!" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(lmstudio_provider): + lmstudio_provider._client = AsyncMock() + await lmstudio_provider.cleanup() + + +# --- Context-budget preflight (new: guards against LM Studio's silent +# mid-stream truncation when a prompt exceeds the loaded model's context) --- + + +def test_preflight_context_budget_noop_when_context_length_unknown(lmstudio_provider): + """No LM Studio /api/v0/models data available -> preflight is a no-op (fail open).""" + with patch.object(lmstudio_provider, "_loaded_context_length", return_value=None): + lmstudio_provider._preflight_context_budget(make_request()) # must not raise + + +def test_preflight_context_budget_allows_request_under_budget(lmstudio_provider): + with patch.object( + lmstudio_provider, "_loaded_context_length", return_value=100_000 + ): + req = make_request( + messages=[{"role": "user", "content": "hi"}], system=None, tools=[] + ) + lmstudio_provider._preflight_context_budget(req) # must not raise + + +def test_preflight_context_budget_rejects_request_over_90_percent(lmstudio_provider): + with ( + patch.object(lmstudio_provider, "_loaded_context_length", return_value=1000), + patch( + "free_claude_code.providers.lmstudio.client.get_token_count", + return_value=901, + ), + pytest.raises(InvalidRequestError, match="prompt is too long"), + ): + lmstudio_provider._preflight_context_budget(make_request()) + + +def test_loaded_context_length_reads_max_across_loaded_models(lmstudio_provider): + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = { + "data": [ + {"state": "loaded", "loaded_context_length": 40960}, + {"state": "loaded", "loaded_context_length": 8192}, + {"state": "not-loaded", "loaded_context_length": 999999}, + ] + } + with patch( + "free_claude_code.providers.lmstudio.client.httpx.get", return_value=response + ) as mock_get: + value = lmstudio_provider._loaded_context_length() + + assert value == 40960 + mock_get.assert_called_once() + assert mock_get.call_args[0][0] == "http://localhost:1234/api/v0/models" + + +def test_loaded_context_length_fails_open_on_error(lmstudio_provider): + with patch( + "free_claude_code.providers.lmstudio.client.httpx.get", + side_effect=httpx.ConnectError("refused"), + ): + assert lmstudio_provider._loaded_context_length() is None + + +def test_loaded_context_length_is_cached_within_ttl(lmstudio_provider): + response = MagicMock() + response.raise_for_status = MagicMock() + response.json.return_value = { + "data": [{"state": "loaded", "loaded_context_length": 40960}] + } + with patch( + "free_claude_code.providers.lmstudio.client.httpx.get", return_value=response + ) as mock_get: + first = lmstudio_provider._loaded_context_length() + second = lmstudio_provider._loaded_context_length() + + assert first == second == 40960 + mock_get.assert_called_once() diff --git a/tests/providers/test_minimax.py b/tests/providers/test_minimax.py new file mode 100644 index 0000000..75b6788 --- /dev/null +++ b/tests/providers/test_minimax.py @@ -0,0 +1,168 @@ +"""Tests for the MiniMax OpenAI-chat provider.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.config.provider_catalog import MINIMAX_DEFAULT_BASE +from free_claude_code.core.anthropic.models import Message, MessagesRequest, Tool +from free_claude_code.core.anthropic.stream_contracts import ( + parse_sse_text, + text_content, + thinking_content, +) +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +class AsyncStream: + def __init__(self, chunks): + self._chunks = chunks + self.closed = False + + def __aiter__(self): + return self._iter() + + async def _iter(self): + for chunk in self._chunks: + yield chunk + + async def aclose(self): + self.closed = True + + +@pytest.fixture +def minimax_provider(): + return profiled_provider( + "minimax", + ProviderConfig( + api_key="test-minimax-key", + base_url=MINIMAX_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ), + rate_limiter=passthrough_rate_limiter(), + ) + + +def _chunk( + *, + content: str | None = None, + reasoning_content: str | None = None, + finish_reason: str | None = None, +): + delta = SimpleNamespace( + content=content, + reasoning_content=reasoning_content, + tool_calls=None, + ) + return SimpleNamespace( + choices=[SimpleNamespace(delta=delta, finish_reason=finish_reason)], + usage=None, + ) + + +def test_default_base_url(): + assert MINIMAX_DEFAULT_BASE == "https://api.minimax.io/v1" + + +def test_init_uses_openai_chat_provider(minimax_provider): + assert isinstance(minimax_provider, OpenAIChatProvider) + assert minimax_provider._api_key == "test-minimax-key" + assert minimax_provider._base_url == MINIMAX_DEFAULT_BASE + assert minimax_provider._provider_name == "MINIMAX" + + +def test_build_request_body_uses_adaptive_thinking_and_max_completion_tokens( + minimax_provider, +): + request = MessagesRequest.model_validate( + { + "model": "MiniMax-M3", + "messages": [Message(role="user", content="Hello")], + "tools": [ + Tool( + name="echo", + description="Echo input", + input_schema={"type": "object", "properties": {}}, + ) + ], + "thinking": {"type": "enabled", "budget_tokens": 2048}, + } + ) + + body = minimax_provider._build_request_body(request) + + assert body["model"] == "MiniMax-M3" + assert body["tools"][0]["function"]["name"] == "echo" + assert body["max_completion_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + assert "max_tokens" not in body + assert body["extra_body"]["reasoning_split"] is True + assert body["extra_body"]["thinking"] == {"type": "adaptive"} + + +def test_build_request_body_honors_no_thinking(minimax_provider): + request = MessagesRequest( + model="MiniMax-M3", + messages=[Message(role="user", content="Hello")], + ) + + body = minimax_provider._build_request_body(request, thinking_enabled=False) + + assert body["extra_body"]["thinking"] == {"type": "disabled"} + + +@pytest.mark.asyncio +async def test_lists_models_from_openai_models_endpoint(minimax_provider): + minimax_provider._client.models.list = AsyncMock( + return_value=SimpleNamespace( + data=[SimpleNamespace(id="MiniMax-M3"), SimpleNamespace(id="MiniMax-M2.7")] + ) + ) + + assert await minimax_provider.list_model_ids() == frozenset( + {"MiniMax-M3", "MiniMax-M2.7"} + ) + + +@pytest.mark.asyncio +async def test_stream_preserves_reasoning_content(minimax_provider): + request = MessagesRequest( + model="MiniMax-M3", + messages=[Message(role="user", content="hi")], + ) + stream = AsyncStream( + [ + _chunk(reasoning_content="plan"), + _chunk(content="done", finish_reason="stop"), + ] + ) + + with patch.object( + minimax_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream, + ) as create: + events = [event async for event in minimax_provider.stream_response(request)] + + parsed = parse_sse_text("".join(events)) + assert thinking_content(parsed) == "plan" + assert text_content(parsed) == "done" + assert create.await_args is not None + assert create.await_args.kwargs["extra_body"]["reasoning_split"] is True + assert stream.closed + + +@pytest.mark.asyncio +async def test_cleanup_closes_openai_client(minimax_provider): + minimax_provider._client = MagicMock() + minimax_provider._client.close = AsyncMock() + + await minimax_provider.cleanup() + + minimax_provider._client.close.assert_awaited_once() diff --git a/tests/providers/test_mistral.py b/tests/providers/test_mistral.py new file mode 100644 index 0000000..b59dfc7 --- /dev/null +++ b/tests/providers/test_mistral.py @@ -0,0 +1,764 @@ +"""Tests for Mistral La Plateforme provider.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import openai +import pytest +from httpx import Request, Response + +from free_claude_code.config.provider_catalog import MISTRAL_DEFAULT_BASE +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.mistral import MistralProvider +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +def make_request(**overrides): + return make_messages_request("devstral-small-latest", **overrides) + + +@pytest.fixture +def mistral_config(): + return ProviderConfig( + api_key="test_mistral_key", + base_url=MISTRAL_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def mistral_provider(mistral_config): + return MistralProvider(mistral_config, rate_limiter=passthrough_rate_limiter()) + + +def test_init(mistral_config): + """Test provider initialization.""" + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = MistralProvider( + mistral_config, rate_limiter=passthrough_rate_limiter() + ) + assert provider._api_key == "test_mistral_key" + assert provider._base_url == MISTRAL_DEFAULT_BASE + mock_openai.assert_called_once() + + +def test_default_base_url(): + assert MISTRAL_DEFAULT_BASE == "https://api.mistral.ai/v1" + + +def test_build_request_body_basic(mistral_provider): + """Basic request body conversion works for Mistral.""" + req = make_request() + body = mistral_provider._build_request_body(req) + + assert body["model"] == "devstral-small-latest" + assert body["messages"][0]["role"] == "system" + assert body["reasoning_effort"] == "high" + + +def test_build_request_body_replays_prior_thinking_as_mistral_chunks( + mistral_provider, +): + req = make_request( + system=None, + messages=[ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Need the tool."}, + {"type": "text", "text": "Calling the tool."}, + { + "type": "tool_use", + "id": "toolu_1", + "name": "echo", + "input": {"value": "x"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_1", + "content": "result", + } + ], + }, + ], + ) + + body = mistral_provider._build_request_body(req) + + assistant = body["messages"][0] + assert "reasoning_content" not in assistant + assert assistant["content"] == [ + { + "type": "thinking", + "thinking": [{"type": "text", "text": "Need the tool."}], + }, + {"type": "text", "text": "Calling the tool."}, + ] + assert assistant["tool_calls"][0]["id"] == "toolu_1" + + +def test_build_request_body_preserves_tools_tool_choice_and_params(mistral_provider): + req = make_request( + tools=[ + { + "name": "echo", + "description": "Echo a value", + "input_schema": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + } + ], + tool_choice={"type": "tool", "name": "echo"}, + stop_sequences=["STOP"], + ) + + body = mistral_provider._build_request_body(req) + + assert body["max_tokens"] == 100 + assert body["temperature"] == 0.5 + assert body["top_p"] == 0.9 + assert body["stop"] == ["STOP"] + assert body["tools"][0]["function"]["name"] == "echo" + assert body["tool_choice"] == {"type": "function", "function": {"name": "echo"}} + + +def test_build_request_body_global_disable_blocks_reasoning_mapping(): + """Global disable disables reasoning replay in the converter.""" + provider = MistralProvider( + ProviderConfig( + api_key="test_mistral_key", + base_url=MISTRAL_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + body = provider._build_request_body(req) + + assert "reasoning_effort" not in body + assert all("reasoning_content" not in m for m in body.get("messages", [])) + + +def test_build_request_body_thinking_disabled_strips_prior_mistral_thinking(): + provider = MistralProvider( + ProviderConfig( + api_key="test_mistral_key", + base_url=MISTRAL_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request( + system=None, + messages=[ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Hidden."}, + {"type": "text", "text": "Visible."}, + ], + }, + ], + ) + + body = provider._build_request_body(req) + + assert "reasoning_effort" not in body + assert body["messages"][0]["content"] == "Visible." + + +@pytest.mark.asyncio +async def test_stream_response_text(mistral_provider): + """Text content deltas are emitted as text blocks.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello back!", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in mistral_provider.stream_response(req)] + + assert any( + '"text_delta"' in event and "Hello back!" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(mistral_provider): + """reasoning_content deltas are emitted as thinking blocks.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking...", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in mistral_provider.stream_response(req)] + + assert any( + '"thinking_delta"' in event and "Thinking..." in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_native_mistral_thinking_chunk(mistral_provider): + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=[ + { + "type": "thinking", + "thinking": [{"type": "text", "text": "Native thought."}], + } + ], + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in mistral_provider.stream_response(req)] + + assert any( + '"thinking_delta"' in event and "Native thought." in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_native_mistral_text_chunk(mistral_provider): + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=[{"type": "text", "text": "Native text."}], + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in mistral_provider.stream_response(req)] + + assert any('"text_delta"' in event and "Native text." in event for event in events) + + +@pytest.mark.asyncio +async def test_stream_response_preserves_native_thinking_and_string_text( + mistral_provider, +): + req = make_request() + + mock_chunk = SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace( + content="Visible token.", + thinking="Native thought.", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ), + ], + usage=MagicMock(completion_tokens=2, prompt_tokens=10), + ) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in mistral_provider.stream_response(req)] + + event_text = "\n".join(events) + assert '"thinking_delta"' in event_text + assert "Native thought." in event_text + assert '"text_delta"' in event_text + assert "Visible token." in event_text + + +@pytest.mark.asyncio +async def test_stream_response_preserves_native_reasoning_and_string_text( + mistral_provider, +): + req = make_request() + + mock_chunk = SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace( + content="Visible token.", + reasoning="Native reasoning.", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ], + usage=MagicMock(completion_tokens=2, prompt_tokens=10), + ) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in mistral_provider.stream_response(req)] + + event_text = "\n".join(events) + assert "Native reasoning." in event_text + assert "Visible token." in event_text + + +@pytest.mark.asyncio +async def test_stream_response_preserves_mixed_native_content_array( + mistral_provider, +): + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=[ + { + "type": "thinking", + "thinking": [{"type": "text", "text": "Native thought."}], + }, + {"type": "text", "text": "Native text."}, + ], + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [event async for event in mistral_provider.stream_response(req)] + + event_text = "\n".join(events) + assert "Native thought." in event_text + assert "Native text." in event_text + + +@pytest.mark.asyncio +async def test_stream_response_suppresses_native_mistral_thinking_when_disabled( + mistral_provider, +): + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=[ + { + "type": "thinking", + "thinking": [{"type": "text", "text": "Hidden."}], + }, + {"type": "text", "text": "Visible."}, + ], + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event + async for event in mistral_provider.stream_response( + req, thinking_enabled=False + ) + ] + + event_text = "\n".join(events) + assert "Hidden." not in event_text + assert "Visible." in event_text + + +@pytest.mark.asyncio +async def test_stream_response_retries_without_mistral_reasoning_on_rejection( + mistral_provider, +): + req = make_request( + system=None, + messages=[ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Need the tool."}, + { + "type": "tool_use", + "id": "toolu_reasoning", + "name": "echo", + "input": {"value": "FCC_TOOL"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_reasoning", + "content": "result", + } + ], + }, + ], + ) + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Recovered", reasoning_content=None, tool_calls=None + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + error = _make_bad_request_error("Unsupported field: reasoning_effort") + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [error, mock_stream()] + + events = [e async for e in mistral_provider.stream_response(req)] + + assert mock_create.await_count == 2 + first_call = mock_create.await_args_list[0].kwargs + second_call = mock_create.await_args_list[1].kwargs + assert first_call["reasoning_effort"] == "high" + assert first_call["messages"][0]["content"][0]["type"] == "thinking" + assert "reasoning_effort" not in second_call + assert second_call["messages"][0]["content"] == "" + assert second_call["messages"][0]["tool_calls"][0]["id"] == "toolu_reasoning" + assert any("Recovered" in event for event in events) + assert any("message_stop" in event for event in events) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_retry_preserves_visible_text_and_tools( + mistral_provider, +): + req = make_request( + system=None, + messages=[ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Need the tool."}, + {"type": "text", "text": "Visible history."}, + { + "type": "tool_use", + "id": "toolu_reasoning", + "name": "echo", + "input": {"value": "FCC_TOOL"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_reasoning", + "content": "result", + } + ], + }, + ], + ) + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="Recovered", reasoning_content=None), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + error = _make_bad_request_error("Unsupported field: reasoning_effort") + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [error, mock_stream()] + + events = [e async for e in mistral_provider.stream_response(req)] + + second_call = mock_create.await_args_list[1].kwargs + assert second_call["messages"][0]["content"] == "Visible history." + assert second_call["messages"][0]["tool_calls"][0]["id"] == "toolu_reasoning" + assert any("Recovered" in event for event in events) + + +@pytest.mark.asyncio +async def test_stream_response_retries_on_mistral_422_reasoning_rejection( + mistral_provider, +): + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="Recovered", reasoning_content=None), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + error = _StatusError( + "body.messages.assistant.thinking extra_forbidden", + status_code=422, + body={"detail": [{"loc": ["body", "reasoning_effort"]}]}, + ) + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [error, mock_stream()] + + events = [e async for e in mistral_provider.stream_response(req)] + + assert mock_create.await_count == 2 + assert "reasoning_effort" not in mock_create.await_args_list[1].kwargs + assert any("Recovered" in event for event in events) + + +@pytest.mark.asyncio +async def test_stream_response_retries_when_model_disables_reasoning_input( + mistral_provider, +): + req = make_request( + system=None, + messages=[ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "Need context."}, + {"type": "text", "text": "Visible history."}, + ], + } + ], + ) + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="Recovered", reasoning_content=None), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + error = _StatusError( + "Reasoning input is not enabled for this model", + status_code=400, + body={ + "object": "error", + "message": "Reasoning input is not enabled for this model", + "type": "invalid_request_invalid_args", + "code": "3051", + }, + ) + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [error, mock_stream()] + + events = [e async for e in mistral_provider.stream_response(req)] + + assert mock_create.await_count == 2 + second_call = mock_create.await_args_list[1].kwargs + assert "reasoning_effort" not in second_call + assert second_call["messages"][0]["content"] == "Visible history." + assert any("Recovered" in event for event in events) + + +@pytest.mark.asyncio +async def test_stream_response_unrelated_bad_request_does_not_retry(mistral_provider): + req = make_request() + error = _make_bad_request_error("Unsupported field: top_k") + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = error + + with pytest.raises(ExecutionFailure) as exc_info: + [e async for e in mistral_provider.stream_response(req)] + + assert mock_create.await_count == 1 + assert "Invalid request sent to provider" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_stream_response_generic_thinking_error_does_not_retry( + mistral_provider, +): + req = make_request() + error = _make_bad_request_error("The model was thinking, but top_k is unsupported") + + with patch.object( + mistral_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = error + + with pytest.raises(ExecutionFailure) as exc_info: + [e async for e in mistral_provider.stream_response(req)] + + assert mock_create.await_count == 1 + assert "Invalid request sent to provider" in exc_info.value.message + + +def test_retry_body_without_reasoning_returns_none(mistral_provider): + body = {"model": "x", "messages": [{"role": "user", "content": "hi"}]} + + assert ( + mistral_provider._get_retry_request_body( + _make_bad_request_error("Unsupported field: reasoning_effort"), body + ) + is None + ) + + +@pytest.mark.asyncio +async def test_cleanup(mistral_provider): + """cleanup closes the OpenAI client.""" + mistral_provider._client = AsyncMock() + + await mistral_provider.cleanup() + + mistral_provider._client.close.assert_called_once() + + +def _make_bad_request_error(message: str) -> openai.BadRequestError: + request = Request("POST", "https://api.mistral.ai/v1/chat/completions") + response = Response(400, request=request) + body = {"error": {"message": message}} + return openai.BadRequestError(message, response=response, body=body) + + +class _StatusError(Exception): + def __init__(self, message: str, *, status_code: int, body: dict): + super().__init__(message) + self.status_code = status_code + self.body = body diff --git a/tests/providers/test_model_validation.py b/tests/providers/test_model_validation.py new file mode 100644 index 0000000..233e860 --- /dev/null +++ b/tests/providers/test_model_validation.py @@ -0,0 +1,540 @@ +import asyncio +from collections.abc import AsyncIterator +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.nim import NimSettings +from free_claude_code.config.provider_catalog import ( + DEEPSEEK_DEFAULT_BASE, + NVIDIA_NIM_DEFAULT_BASE, + OPENROUTER_DEFAULT_BASE, + WAFER_DEFAULT_BASE, +) +from free_claude_code.config.settings import Settings +from free_claude_code.providers.base import BaseProvider, ProviderConfig +from free_claude_code.providers.deepseek import DeepSeekProvider +from free_claude_code.providers.model_listing import ModelListResponseError +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from free_claude_code.providers.open_router import OpenRouterProvider +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from free_claude_code.providers.runtime import ProviderRuntime +from free_claude_code.providers.runtime.model_cache import ProviderModelCache +from free_claude_code.runtime.provider_manager import ProviderRuntimeManager +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def _settings( + *, + model: str = "nvidia_nim/nim-model", + model_opus: str | None = None, + model_sonnet: str | None = None, + model_haiku: str | None = None, + nvidia_nim_api_key: str = "", + open_router_api_key: str = "", + deepseek_api_key: str = "", + wafer_api_key: str = "", + opencode_api_key: str = "", + zai_api_key: str = "", +) -> Settings: + return Settings.model_construct( + model=model, + model_opus=model_opus, + model_sonnet=model_sonnet, + model_haiku=model_haiku, + nvidia_nim_api_key=nvidia_nim_api_key, + open_router_api_key=open_router_api_key, + deepseek_api_key=deepseek_api_key, + wafer_api_key=wafer_api_key, + opencode_api_key=opencode_api_key, + zai_api_key=zai_api_key, + log_api_error_tracebacks=False, + ) + + +def _manager( + settings: Settings, + providers: dict[str, BaseProvider] | None = None, +) -> ProviderRuntimeManager: + providers = providers or {} + return ProviderRuntimeManager( + settings, + runtime_factory=lambda snapshot: ProviderRuntime(snapshot, dict(providers)), + ) + + +@pytest.mark.asyncio +async def test_nim_lists_openai_compatible_model_ids() -> None: + config = ProviderConfig(api_key="test-key", base_url=NVIDIA_NIM_DEFAULT_BASE) + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = NvidiaNimProvider( + config, nim_settings=NimSettings(), rate_limiter=passthrough_rate_limiter() + ) + + with patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace(data=[SimpleNamespace(id="nvidia/model")]), + ): + assert await provider.list_model_ids() == frozenset({"nvidia/model"}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "provider", + [ + profiled_provider( + "llamacpp", + ProviderConfig(api_key="llamacpp", base_url="http://localhost:8080/v1"), + rate_limiter=passthrough_rate_limiter(), + ), + profiled_provider( + "ollama", + ProviderConfig(api_key="ollama", base_url="http://localhost:11434"), + rate_limiter=passthrough_rate_limiter(), + ), + ], +) +async def test_local_openai_chat_providers_list_model_ids( + provider: OpenAIChatProvider, +) -> None: + with patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace(data=[SimpleNamespace(id="local/model")]), + ) as mock_list: + assert await provider.list_model_ids() == frozenset({"local/model"}) + + mock_list.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_deepseek_lists_models_from_root_endpoint() -> None: + provider = DeepSeekProvider( + ProviderConfig(api_key="deepseek-key", base_url=DEEPSEEK_DEFAULT_BASE), + rate_limiter=passthrough_rate_limiter(), + ) + with patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace(data=[SimpleNamespace(id="deepseek-chat")]), + ) as mock_list: + assert await provider.list_model_ids() == frozenset({"deepseek-chat"}) + + mock_list.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_wafer_lists_models_from_default_models_endpoint() -> None: + provider = profiled_provider( + "wafer", + ProviderConfig(api_key="wafer-key", base_url=WAFER_DEFAULT_BASE), + rate_limiter=passthrough_rate_limiter(), + ) + with patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace(data=[SimpleNamespace(id="DeepSeek-V4-Pro")]), + ) as mock_list: + assert await provider.list_model_ids() == frozenset({"DeepSeek-V4-Pro"}) + + mock_list.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_openrouter_lists_only_tool_capable_models() -> None: + provider = OpenRouterProvider( + ProviderConfig(api_key="open-router-key", base_url=OPENROUTER_DEFAULT_BASE), + rate_limiter=passthrough_rate_limiter(), + ) + with patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace( + data=[ + SimpleNamespace( + id="tool-model", + supported_parameters=["tools", "max_tokens"], + ), + SimpleNamespace( + id="tool-choice-model", + supported_parameters=["tool_choice"], + ), + SimpleNamespace( + id="chat-only", + supported_parameters=["max_tokens", "temperature"], + ), + SimpleNamespace(id="missing-metadata", supported_parameters=None), + ] + ), + ) as mock_list: + assert await provider.list_model_ids() == frozenset( + {"tool-model", "tool-choice-model"} + ) + + mock_list.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_openrouter_lists_tool_metadata_with_thinking_support() -> None: + provider = OpenRouterProvider( + ProviderConfig(api_key="open-router-key", base_url=OPENROUTER_DEFAULT_BASE), + rate_limiter=passthrough_rate_limiter(), + ) + with patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace( + data=[ + SimpleNamespace( + id="reasoning-tool-model", + supported_parameters=[ + "tools", + "reasoning", + "include_reasoning", + ], + ), + SimpleNamespace( + id="plain-tool-model", + supported_parameters=["tool_choice", "include_reasoning"], + ), + SimpleNamespace( + id="chat-only", + supported_parameters=["reasoning", "max_tokens"], + ), + ] + ), + ): + infos = await provider.list_model_infos() + + assert infos == frozenset( + { + ProviderModelInfo("reasoning-tool-model", supports_thinking=True), + ProviderModelInfo("plain-tool-model", supports_thinking=False), + } + ) + + +@pytest.mark.asyncio +async def test_openrouter_lists_empty_set_when_no_tool_capable_models() -> None: + provider = OpenRouterProvider( + ProviderConfig(api_key="open-router-key", base_url=OPENROUTER_DEFAULT_BASE), + rate_limiter=passthrough_rate_limiter(), + ) + with patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace( + data=[ + SimpleNamespace(id="chat-only", supported_parameters=["max_tokens"]), + SimpleNamespace(id="missing-metadata", supported_parameters=None), + ] + ), + ): + assert await provider.list_model_ids() == frozenset() + + +@pytest.mark.asyncio +async def test_openrouter_model_metadata_rejects_malformed_ids() -> None: + provider = OpenRouterProvider( + ProviderConfig(api_key="open-router-key", base_url=OPENROUTER_DEFAULT_BASE), + rate_limiter=passthrough_rate_limiter(), + ) + with ( + patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace( + data=[SimpleNamespace(supported_parameters=["tools", "reasoning"])] + ), + ), + pytest.raises(ModelListResponseError, match="malformed"), + ): + await provider.list_model_infos() + + +@pytest.mark.asyncio +async def test_model_listing_rejects_malformed_payload() -> None: + provider = profiled_provider( + "llamacpp", + ProviderConfig(api_key="llamacpp", base_url="http://localhost:8080/v1"), + rate_limiter=passthrough_rate_limiter(), + ) + with ( + patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + return_value=SimpleNamespace(data=[SimpleNamespace()]), + ), + pytest.raises(ModelListResponseError, match="malformed"), + ): + await provider.list_model_ids() + + +@pytest.mark.asyncio +async def test_model_listing_propagates_upstream_errors() -> None: + provider = profiled_provider( + "llamacpp", + ProviderConfig(api_key="llamacpp", base_url="http://localhost:8080/v1"), + rate_limiter=passthrough_rate_limiter(), + ) + with ( + patch.object( + provider._client.models, + "list", + new_callable=AsyncMock, + side_effect=RuntimeError("upstream unavailable"), + ), + pytest.raises(RuntimeError, match="upstream unavailable"), + ): + await provider.list_model_ids() + + +class FakeProvider(BaseProvider): + def __init__( + self, + model_ids: frozenset[str] | None = None, + *, + model_infos: frozenset[ProviderModelInfo] | None = None, + error: BaseException | None = None, + started: asyncio.Event | None = None, + peer_started: asyncio.Event | None = None, + ): + super().__init__( + ProviderConfig(api_key="test", base_url="https://test.invalid") + ) + self._model_ids = model_ids or frozenset() + self._model_infos = model_infos + self._error = error + self._started = started + self._peer_started = peer_started + self.cleaned = False + + def preflight_stream( + self, request: Any, *, thinking_enabled: bool | None = None + ) -> None: + return None + + async def cleanup(self) -> None: + self.cleaned = True + + async def _before_model_list(self) -> None: + if self._started is not None: + self._started.set() + if self._peer_started is not None: + await self._peer_started.wait() + if self._error is not None: + raise self._error + + async def list_model_ids(self) -> frozenset[str]: + await self._before_model_list() + if self._model_infos is not None: + return frozenset(info.model_id for info in self._model_infos) + return self._model_ids + + async def list_model_infos(self) -> frozenset[ProviderModelInfo]: + await self._before_model_list() + if self._model_infos is not None: + return self._model_infos + return frozenset(ProviderModelInfo(model_id) for model_id in self._model_ids) + + async def stream_response( + self, + request: Any, + input_tokens: int = 0, + *, + request_id: str | None = None, + thinking_enabled: bool | None = None, + ) -> AsyncIterator[str]: + if False: + yield "" + + +@pytest.mark.asyncio +async def test_runtime_validation_succeeds_for_all_configured_models() -> None: + settings = _settings(model_opus="open_router/anthropic/claude-opus") + runtime = _manager( + settings, + { + "nvidia_nim": FakeProvider(frozenset({"nim-model"})), + "open_router": FakeProvider(frozenset({"anthropic/claude-opus"})), + }, + ) + + await runtime.validate_configured_models() + + assert runtime.cached_model_ids() == { + "nvidia_nim": frozenset({"nim-model"}), + "open_router": frozenset({"anthropic/claude-opus"}), + } + + +@pytest.mark.asyncio +async def test_runtime_validation_reports_missing_model_with_sources() -> None: + settings = _settings(model_sonnet="nvidia_nim/nim-model") + runtime = _manager( + settings, + {"nvidia_nim": FakeProvider(frozenset({"different-model"}))}, + ) + + with pytest.raises(ApplicationUnavailableError) as exc_info: + await runtime.validate_configured_models() + + message = exc_info.value.message + assert "sources=MODEL,MODEL_SONNET" in message + assert "provider=nvidia_nim" in message + assert "model=nim-model" in message + assert "problem=missing model" in message + + +@pytest.mark.asyncio +async def test_runtime_validation_aggregates_multiple_failures() -> None: + settings = _settings(model_opus="open_router/anthropic/claude-opus") + runtime = _manager( + settings, + { + "nvidia_nim": FakeProvider(frozenset({"different-model"})), + "open_router": FakeProvider( + error=ModelListResponseError("bad model-list shape") + ), + }, + ) + + with pytest.raises(ApplicationUnavailableError) as exc_info: + await runtime.validate_configured_models() + + message = exc_info.value.message + assert "sources=MODEL provider=nvidia_nim model=nim-model" in message + assert "problem=missing model" in message + assert "sources=MODEL_OPUS provider=open_router model=anthropic/claude-opus" in ( + message + ) + assert "problem=malformed model-list response" in message + + +@pytest.mark.asyncio +async def test_runtime_validation_queries_providers_concurrently() -> None: + nim_started = asyncio.Event() + router_started = asyncio.Event() + settings = _settings(model_opus="open_router/anthropic/claude-opus") + runtime = _manager( + settings, + { + "nvidia_nim": FakeProvider( + frozenset({"nim-model"}), + started=nim_started, + peer_started=router_started, + ), + "open_router": FakeProvider( + frozenset({"anthropic/claude-opus"}), + started=router_started, + peer_started=nim_started, + ), + }, + ) + + await asyncio.wait_for(runtime.validate_configured_models(), timeout=1.0) + + +@pytest.mark.asyncio +async def test_runtime_refresh_model_list_cache_uses_configured_remote_keys_and_referenced_local() -> ( + None +): + settings = _settings( + model="lmstudio/local-qwen", + open_router_api_key="open-router-key", + ) + runtime = _manager( + settings, + { + "open_router": FakeProvider(frozenset({"anthropic/claude-sonnet"})), + "lmstudio": FakeProvider(frozenset({"local-qwen"})), + "ollama": FakeProvider(frozenset({"llama3.1"})), + }, + ) + + await runtime.refresh_model_list_cache() + + assert runtime.cached_model_ids() == { + "open_router": frozenset({"anthropic/claude-sonnet"}), + "lmstudio": frozenset({"local-qwen"}), + } + + +@pytest.mark.asyncio +async def test_runtime_refresh_model_list_cache_keeps_prior_cache_on_failure() -> None: + settings = _settings( + model="nvidia_nim/cached-model", + nvidia_nim_api_key="nim-key", + ) + runtime = _manager( + settings, + {"nvidia_nim": FakeProvider(error=RuntimeError("upstream down"))}, + ) + runtime.cache_model_infos( + "nvidia_nim", + {ProviderModelInfo("cached-model")}, + ) + + await runtime.refresh_model_list_cache() + + assert runtime.cached_model_ids() == {"nvidia_nim": frozenset({"cached-model"})} + + +def test_runtime_metadata_cache_exposes_ids_and_prefixed_infos() -> None: + cache = ProviderModelCache() + cache.cache_model_infos( + "open_router", + { + ProviderModelInfo("reasoning-model", supports_thinking=True), + ProviderModelInfo("plain-model", supports_thinking=False), + }, + ) + + assert cache.cached_model_ids() == { + "open_router": frozenset({"reasoning-model", "plain-model"}) + } + assert ( + cache.cached_model_supports_thinking("open_router", "reasoning-model") is True + ) + assert cache.cached_model_supports_thinking("open_router", "plain-model") is False + assert cache.cached_prefixed_model_infos() == ( + ProviderModelInfo("open_router/plain-model", supports_thinking=False), + ProviderModelInfo("open_router/reasoning-model", supports_thinking=True), + ) + + +def test_runtime_model_id_cache_keeps_unknown_thinking_support() -> None: + cache = ProviderModelCache() + cache.cache_model_ids("open_router", {"plain-model"}) + + assert cache.cached_model_ids() == {"open_router": frozenset({"plain-model"})} + assert cache.cached_model_supports_thinking("open_router", "plain-model") is None + assert cache.cached_prefixed_model_infos() == ( + ProviderModelInfo("open_router/plain-model", supports_thinking=None), + ) + + +def test_runtime_cached_prefixed_model_refs_are_deterministic() -> None: + cache = ProviderModelCache() + cache.cache_model_ids("deepseek", {"deepseek-chat"}) + cache.cache_model_ids("open_router", {"z-model", "a-model"}) + + assert cache.cached_prefixed_model_refs() == ( + "open_router/a-model", + "open_router/z-model", + "deepseek/deepseek-chat", + ) diff --git a/tests/providers/test_nim_request_clone.py b/tests/providers/test_nim_request_clone.py new file mode 100644 index 0000000..0e82509 --- /dev/null +++ b/tests/providers/test_nim_request_clone.py @@ -0,0 +1,42 @@ +"""Tests for NVIDIA NIM request body cloning helpers.""" + +from copy import deepcopy + +from free_claude_code.providers.nvidia_nim.retry import ( + clone_body_without_reasoning_budget, +) + + +def test_clone_body_without_reasoning_budget_strips_top_level_and_nested(): + body: dict = { + "model": "x", + "extra_body": { + "reasoning_budget": 99, + "chat_template_kwargs": {"reasoning_budget": 42, "thinking": True}, + "top_k": 1, + }, + } + original_extra = deepcopy(body["extra_body"]) + out = clone_body_without_reasoning_budget(body) + + assert out is not None + assert out["extra_body"]["chat_template_kwargs"] == {"thinking": True} + assert "reasoning_budget" not in out["extra_body"] + assert body["extra_body"] == original_extra + + +def test_clone_body_without_reasoning_budget_returns_none_when_unchanged(): + body = {"model": "x", "extra_body": {"top_k": 3}} + assert clone_body_without_reasoning_budget(body) is None + + +def test_clone_body_without_reasoning_budget_returns_none_without_extra_body(): + assert clone_body_without_reasoning_budget({"model": "y"}) is None + + +def test_clone_body_drops_empty_extra_body_after_strip(): + body = {"model": "z", "extra_body": {"reasoning_budget": 7}} + out = clone_body_without_reasoning_budget(body) + assert out is not None + assert "extra_body" not in out + assert "extra_body" in body diff --git a/tests/providers/test_nvidia_nim.py b/tests/providers/test_nvidia_nim.py new file mode 100644 index 0000000..499f37a --- /dev/null +++ b/tests/providers/test_nvidia_nim.py @@ -0,0 +1,933 @@ +import json +from dataclasses import replace +from unittest.mock import AsyncMock, MagicMock, patch + +import openai +import pytest +from httpx import Request, Response + +from free_claude_code.config.nim import NimSettings +from free_claude_code.config.provider_catalog import NVIDIA_NIM_DEFAULT_BASE +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from free_claude_code.providers.nvidia_nim.tool_schema import ( + NIM_TOOL_ARGUMENT_ALIASES_KEY, +) +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +def message(role, content): + return {"role": role, "content": content} + + +def tool(name, description, input_schema): + return {"name": name, "description": description, "input_schema": input_schema} + + +def block(**fields): + return fields + + +def make_request(**overrides): + model = overrides.pop("model", "test-model") + overrides.setdefault("stop_sequences", ["STOP"]) + return make_messages_request(model, **overrides) + + +def _input_json_deltas(events): + deltas = [] + for event in events: + if "event: content_block_delta" not in event: + continue + for line in event.splitlines(): + if not line.startswith("data: "): + continue + payload = json.loads(line[6:]) + delta = payload.get("delta", {}) + if delta.get("type") == "input_json_delta": + deltas.append(delta.get("partial_json", "")) + return deltas + + +def _tool_call_chunk( + *, + name, + arguments, + tool_id="call_1", + index=0, + finish_reason=None, +): + mock_tc = MagicMock() + mock_tc.index = index + mock_tc.id = tool_id + mock_tc.function.name = name + mock_tc.function.arguments = arguments + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content=None, reasoning_content="", tool_calls=[mock_tc]), + finish_reason=finish_reason, + ) + ] + mock_chunk.usage = None + return mock_chunk + + +def _make_bad_request_error(message: str) -> openai.BadRequestError: + response = Response( + status_code=400, + request=Request("POST", f"{NVIDIA_NIM_DEFAULT_BASE}/chat/completions"), + ) + body = {"error": {"message": message, "type": "BadRequestError", "code": 400}} + return openai.BadRequestError(message, response=response, body=body) + + +def _make_internal_server_error(message: str) -> openai.InternalServerError: + response = Response( + status_code=500, + request=Request("POST", f"{NVIDIA_NIM_DEFAULT_BASE}/chat/completions"), + ) + body = { + "error": { + "message": message, + "type": "internal_server_error", + "code": 500, + } + } + return openai.InternalServerError(message, response=response, body=body) + + +@pytest.mark.asyncio +async def test_init(provider_config): + """Test provider initialization.""" + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = NvidiaNimProvider( + provider_config, + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + assert provider._api_key == "test_key" + assert provider._base_url == "https://test.api.nvidia.com/v1" + mock_openai.assert_called_once() + + +@pytest.mark.asyncio +async def test_init_uses_configurable_timeouts(): + """Test that provider passes configurable read/write/connect timeouts to client.""" + from free_claude_code.providers.base import ProviderConfig + + config = ProviderConfig( + api_key="test_key", + base_url="https://test.api.nvidia.com/v1", + http_read_timeout=600.0, + http_write_timeout=15.0, + http_connect_timeout=5.0, + ) + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + NvidiaNimProvider( + config, nim_settings=NimSettings(), rate_limiter=passthrough_rate_limiter() + ) + call_kwargs = mock_openai.call_args[1] + timeout = call_kwargs["timeout"] + assert timeout.read == 600.0 + assert timeout.write == 15.0 + assert timeout.connect == 5.0 + + +@pytest.mark.asyncio +async def test_build_request_body(provider_config): + """Test request body construction.""" + provider = NvidiaNimProvider( + provider_config, + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + body = provider._build_request_body(req) + + assert body["model"] == "test-model" + assert body["temperature"] == 0.5 + assert len(body["messages"]) == 2 # System + User + assert body["messages"][0]["role"] == "system" + assert body["messages"][0]["content"] == "System prompt" + + assert "extra_body" in body + ctk = body["extra_body"]["chat_template_kwargs"] + assert ctk["thinking"] is True + assert ctk["enable_thinking"] is True + assert ctk["reasoning_budget"] == body["max_tokens"] + assert "reasoning_budget" not in body["extra_body"] + + +@pytest.mark.asyncio +async def test_build_request_body_omits_reasoning_when_globally_disabled( + provider_config, +): + provider = NvidiaNimProvider( + replace(provider_config, enable_thinking=False), + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + body = provider._build_request_body(req) + + extra = body.get("extra_body", {}) + assert "chat_template_kwargs" not in extra + assert "reasoning_budget" not in extra + + +@pytest.mark.asyncio +async def test_build_request_body_omits_reasoning_when_request_disables_thinking( + provider_config, +): + provider = NvidiaNimProvider( + provider_config, + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + req.thinking.enabled = False + body = provider._build_request_body(req) + + extra = body.get("extra_body", {}) + assert "chat_template_kwargs" not in extra + assert "reasoning_budget" not in extra + + +def test_preflight_and_build_request_issue_206_post_tool_text(nim_provider): + """Regression: assistant message with tool_use then text plus tool results (GitHub #206).""" + tool_id = "toolu_issue_206" + req = make_request( + messages=[ + message("user", "Use echo once."), + message( + "assistant", + [ + block( + type="tool_use", + id=tool_id, + name="echo_smoke", + input={"value": "FCC_206"}, + ), + block( + type="text", + text="Commentary after the tool row.", + ), + ], + ), + message( + "user", + [ + block(type="tool_result", tool_use_id=tool_id, content="FCC_206"), + block(type="text", text="What was echoed?"), + ], + ), + ], + ) + nim_provider.preflight_stream(req, thinking_enabled=False) + body = nim_provider._build_request_body(req, thinking_enabled=False) + assert "messages" in body + assert any(m.get("role") == "tool" for m in body["messages"]) + + +@pytest.mark.asyncio +async def test_stream_response_text(nim_provider): + """Test streaming text response.""" + req = make_request() + + # Create mock chunks + mock_chunk1 = MagicMock() + mock_chunk1.choices = [ + MagicMock( + delta=MagicMock(content="Hello", reasoning_content=""), finish_reason=None + ) + ] + mock_chunk1.usage = None + + mock_chunk2 = MagicMock() + mock_chunk2.choices = [ + MagicMock( + delta=MagicMock(content=" World", reasoning_content=""), + finish_reason="stop", + ) + ] + mock_chunk2.usage = MagicMock(completion_tokens=10) + + async def mock_stream(): + yield mock_chunk1 + yield mock_chunk2 + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [e async for e in nim_provider.stream_response(req)] + + assert len(events) > 0 + assert "event: message_start" in events[0] + + text_content = "" + for e in events: + if "event: content_block_delta" in e and '"text_delta"' in e: + for line in e.splitlines(): + if line.startswith("data: "): + data = json.loads(line[6:]) + if "delta" in data and "text" in data["delta"]: + text_content += data["delta"]["text"] + + assert "Hello World" in text_content + + +@pytest.mark.asyncio +async def test_stream_response_thinking_reasoning_content(nim_provider): + """Test streaming with native reasoning_content.""" + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content=None, reasoning_content="Thinking..."), + finish_reason=None, + ) + ] + mock_chunk.usage = None + stop_chunk = MagicMock() + stop_chunk.choices = [ + MagicMock( + delta=MagicMock(content=None, reasoning_content=None, tool_calls=None), + finish_reason="stop", + ) + ] + stop_chunk.usage = None + + async def mock_stream(): + yield mock_chunk + yield stop_chunk + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [e async for e in nim_provider.stream_response(req)] + + # Check for thinking_delta + found_thinking = False + for e in events: + if ( + "event: content_block_delta" in e + and '"thinking_delta"' in e + and "Thinking..." in e + ): + found_thinking = True + assert found_thinking + + +@pytest.mark.asyncio +async def test_stream_response_suppresses_thinking_when_disabled(provider_config): + provider = NvidiaNimProvider( + replace(provider_config, enable_thinking=False), + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="secretAnswer", reasoning_content="Thinking..." + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = None + + async def mock_stream(): + yield mock_chunk + + with patch.object( + provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [e async for e in provider.stream_response(req)] + + event_text = "".join(events) + assert "thinking_delta" not in event_text + assert "Thinking..." not in event_text + assert "secret" not in event_text + assert "Answer" in event_text + + +def _make_bad_request_error(message: str) -> openai.BadRequestError: + response = Response(status_code=400, request=Request("POST", "http://test")) + body = {"error": {"message": message}} + return openai.BadRequestError(message, response=response, body=body) + + +@pytest.mark.asyncio +async def test_stream_response_retries_without_chat_template(provider_config): + provider = NvidiaNimProvider( + provider_config, + nim_settings=NimSettings(chat_template="custom_template"), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request(model="mistralai/mixtral-8x7b-instruct-v0.1") + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="OK", reasoning_content=""), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2) + + async def mock_stream(): + yield mock_chunk + + first_error = _make_bad_request_error( + "chat_template is not supported for Mistral tokenizers." + ) + + with patch.object( + provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [first_error, mock_stream()] + + events = [e async for e in provider.stream_response(req)] + + assert mock_create.await_count == 2 + + first_extra = mock_create.call_args_list[0].kwargs["extra_body"] + second_extra = mock_create.call_args_list[1].kwargs["extra_body"] + + assert first_extra["chat_template"] == "custom_template" + assert first_extra["chat_template_kwargs"] == { + "thinking": True, + "enable_thinking": True, + "reasoning_budget": 100, + } + assert "reasoning_budget" not in first_extra + + assert "chat_template" not in second_extra + assert "chat_template_kwargs" not in second_extra + assert "reasoning_budget" not in second_extra + + event_text = "".join(events) + assert "event: error" not in event_text + assert "OK" in event_text + + +@pytest.mark.asyncio +async def test_stream_response_retries_without_chat_template_kwargs_issue_993( + provider_config, +): + provider = NvidiaNimProvider( + provider_config, + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request(model="mistralai/mistral-small-4-119b-2603") + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="OK", reasoning_content=""), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2) + + async def mock_stream(): + yield mock_chunk + + first_error = _make_bad_request_error( + "chat_template is not supported for Mistral tokenizers." + ) + + with patch.object( + provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [first_error, mock_stream()] + + events = [e async for e in provider.stream_response(req)] + + assert mock_create.await_count == 2 + + first_extra = mock_create.call_args_list[0].kwargs["extra_body"] + second_kwargs = mock_create.call_args_list[1].kwargs + + assert "chat_template" not in first_extra + assert first_extra["chat_template_kwargs"] == { + "thinking": True, + "enable_thinking": True, + "reasoning_budget": 100, + } + second_extra = second_kwargs.get("extra_body") or {} + assert "chat_template" not in second_extra + assert "chat_template_kwargs" not in second_extra + + event_text = "".join(events) + assert "event: error" not in event_text + assert "OK" in event_text + + +@pytest.mark.asyncio +async def test_stream_response_does_not_retry_unrelated_bad_request(provider_config): + provider = NvidiaNimProvider( + provider_config, + nim_settings=NimSettings(chat_template="custom_template"), + rate_limiter=passthrough_rate_limiter(), + ) + req = make_request(model="mistralai/mixtral-8x7b-instruct-v0.1") + + with patch.object( + provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = _make_bad_request_error("unrelated bad request") + + with pytest.raises(ExecutionFailure) as exc_info: + [e async for e in provider.stream_response(req)] + + assert mock_create.await_count == 1 + assert "Invalid request sent to provider" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_tool_call_stream(nim_provider): + """Test streaming tool calls.""" + req = make_request() + + # Mock tool call delta + mock_tc = MagicMock() + mock_tc.index = 0 + mock_tc.id = "call_1" + mock_tc.function.name = "search" + mock_tc.function.arguments = '{"q": "test"}' + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content=None, reasoning_content="", tool_calls=[mock_tc]), + finish_reason=None, + ) + ] + mock_chunk.usage = None + + async def mock_stream(): + yield mock_chunk + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [e async for e in nim_provider.stream_response(req)] + + starts = [ + e for e in events if "event: content_block_start" in e and '"tool_use"' in e + ] + assert len(starts) == 1 + assert "search" in starts[0] + + +@pytest.mark.asyncio +async def test_stream_response_restores_aliased_tool_arguments(nim_provider): + """NIM-safe argument aliases are restored before Anthropic SSE emission.""" + req = make_request( + tools=[ + tool( + "Grep", + "Search file contents", + { + "type": "object", + "properties": { + "pattern": {"type": "string"}, + "-A": {"type": "number"}, + "type": {"type": "string"}, + }, + "required": ["pattern"], + }, + ) + ] + ) + mock_chunk = _tool_call_chunk( + name="Grep", + arguments=json.dumps({"pattern": "needle", "-A": 2, "_fcc_arg_type": "py"}), + ) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [e async for e in nim_provider.stream_response(req)] + + await_args = mock_create.await_args + assert await_args is not None + create_kwargs = await_args.kwargs + assert NIM_TOOL_ARGUMENT_ALIASES_KEY not in create_kwargs + properties = create_kwargs["tools"][0]["function"]["parameters"]["properties"] + assert "-A" in properties + assert "type" not in properties + assert "_fcc_arg_A" not in properties + assert "_fcc_arg_type" in properties + + deltas = _input_json_deltas(events) + assert len(deltas) == 1 + assert json.loads(deltas[0]) == {"pattern": "needle", "-A": 2, "type": "py"} + assert "_fcc_arg_type" not in deltas[0] + + +@pytest.mark.asyncio +async def test_stream_response_buffers_chunked_aliased_tool_arguments(nim_provider): + """Chunked aliased args are emitted once as restored Claude Code args.""" + req = make_request( + tools=[ + tool( + "Grep", + "Search file contents", + { + "type": "object", + "properties": { + "pattern": {"type": "string"}, + "type": {"type": "string"}, + }, + "required": ["pattern"], + }, + ) + ] + ) + first_chunk = _tool_call_chunk( + name="Grep", + arguments='{"pattern": "needle", ', + tool_id="call_chunked", + ) + second_chunk = _tool_call_chunk( + name=None, + arguments='"_fcc_arg_type": "py"}', + tool_id="call_chunked", + ) + + async def mock_stream(): + yield first_chunk + yield second_chunk + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [e async for e in nim_provider.stream_response(req)] + + deltas = _input_json_deltas(events) + assert len(deltas) == 1 + assert json.loads(deltas[0]) == {"pattern": "needle", "type": "py"} + + +@pytest.mark.asyncio +async def test_stream_response_restores_nested_aliased_tool_arguments(nim_provider): + req = make_request( + tools=[ + tool( + "NotionLike", + "Nested type schema", + { + "type": "object", + "properties": { + "parent": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "id": {"type": "string"}, + }, + "required": ["type", "id"], + } + }, + "required": ["parent"], + }, + ) + ] + ) + mock_chunk = _tool_call_chunk( + name="NotionLike", + arguments=json.dumps( + {"parent": {"_fcc_arg_type": "page_id", "id": "page_123"}} + ), + ) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [e async for e in nim_provider.stream_response(req)] + + deltas = _input_json_deltas(events) + assert len(deltas) == 1 + assert json.loads(deltas[0]) == {"parent": {"type": "page_id", "id": "page_123"}} + + +@pytest.mark.asyncio +async def test_stream_response_task_tool_still_forces_background_false(nim_provider): + req = make_request( + tools=[ + tool( + "Task", + "Run a subagent", + { + "type": "object", + "properties": { + "description": {"type": "string"}, + "prompt": {"type": "string"}, + "run_in_background": {"type": "boolean"}, + }, + "required": ["description", "prompt"], + }, + ) + ] + ) + mock_chunk = _tool_call_chunk( + name="Task", + arguments=json.dumps( + { + "description": "Inspect", + "prompt": "Read the marker", + "run_in_background": True, + } + ), + tool_id="call_task", + ) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [e async for e in nim_provider.stream_response(req)] + + deltas = _input_json_deltas(events) + assert len(deltas) == 1 + assert json.loads(deltas[0])["run_in_background"] is False + + +@pytest.mark.asyncio +async def test_stream_response_retries_without_reasoning_budget(nim_provider): + req = make_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="Recovered", reasoning_content=""), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5) + + async def mock_stream(): + yield mock_chunk + + error = _make_bad_request_error("Unsupported field: reasoning_budget") + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [error, mock_stream()] + + events = [e async for e in nim_provider.stream_response(req)] + + assert mock_create.await_count == 2 + first_call = mock_create.await_args_list[0].kwargs + second_call = mock_create.await_args_list[1].kwargs + assert ( + first_call["extra_body"]["chat_template_kwargs"]["reasoning_budget"] + == first_call["max_tokens"] + ) + assert "reasoning_budget" not in second_call["extra_body"] + assert "reasoning_budget" not in second_call["extra_body"]["chat_template_kwargs"] + assert second_call["extra_body"]["chat_template_kwargs"]["enable_thinking"] is True + assert any("Recovered" in event for event in events) + assert any("message_stop" in event for event in events) + + +@pytest.mark.asyncio +async def test_stream_response_retries_without_budget_for_thinking_token_error( + nim_provider, +): + req = make_request(model="meta/llama-3.3-70b-instruct") + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="Recovered", reasoning_content=""), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5) + + async def mock_stream(): + yield mock_chunk + + error = _make_internal_server_error( + "ValueError: thinking_token_budget is set but reasoning_config is not " + "configured. Please set --reasoning-config to use thinking_token_budget." + ) + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [error, mock_stream()] + + events = [e async for e in nim_provider.stream_response(req)] + + assert mock_create.await_count == 2 + first_call = mock_create.await_args_list[0].kwargs + second_call = mock_create.await_args_list[1].kwargs + assert ( + first_call["extra_body"]["chat_template_kwargs"]["reasoning_budget"] + == first_call["max_tokens"] + ) + assert "reasoning_budget" not in second_call["extra_body"] + assert "reasoning_budget" not in second_call["extra_body"]["chat_template_kwargs"] + assert second_call["extra_body"]["chat_template_kwargs"]["thinking"] is True + assert second_call["extra_body"]["chat_template_kwargs"]["enable_thinking"] is True + assert any("Recovered" in event for event in events) + assert any("message_stop" in event for event in events) + + +@pytest.mark.asyncio +async def test_stream_response_retries_without_reasoning_content(nim_provider): + req = make_request( + system=None, + messages=[ + message( + "assistant", + [ + block(type="thinking", thinking="Need the tool."), + block( + type="tool_use", + id="toolu_reasoning", + name="echo_smoke", + input={"value": "FCC_TOOL"}, + ), + ], + ), + message( + "user", + [ + block( + type="tool_result", + tool_use_id="toolu_reasoning", + content="result", + ) + ], + ), + ], + ) + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="Recovered", reasoning_content=""), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5) + + async def mock_stream(): + yield mock_chunk + + error = _make_bad_request_error("Unsupported field: reasoning_content") + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = [error, mock_stream()] + + events = [e async for e in nim_provider.stream_response(req)] + + assert mock_create.await_count == 2 + first_call = mock_create.await_args_list[0].kwargs + second_call = mock_create.await_args_list[1].kwargs + assert first_call["messages"][0]["reasoning_content"] == "Need the tool." + assert "reasoning_content" not in second_call["messages"][0] + assert second_call["messages"][0]["tool_calls"][0]["id"] == "toolu_reasoning" + assert any("Recovered" in event for event in events) + assert any("message_stop" in event for event in events) + + +@pytest.mark.asyncio +async def test_stream_response_bad_request_without_reasoning_budget_does_not_retry( + nim_provider, +): + req = make_request() + error = _make_bad_request_error("Unsupported field: top_k") + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = error + + with pytest.raises(ExecutionFailure) as exc_info: + [e async for e in nim_provider.stream_response(req)] + + assert mock_create.await_count == 1 + assert "Invalid request sent to provider" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_stream_response_unrelated_internal_error_does_not_downgrade( + nim_provider, +): + req = make_request() + error = _make_internal_server_error("unrelated internal provider failure") + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = error + + with pytest.raises(ExecutionFailure) as exc_info: + [e async for e in nim_provider.stream_response(req)] + + assert mock_create.await_count == 1 + assert "Provider API request failed" in exc_info.value.message + + +@pytest.mark.asyncio +async def test_stream_response_internal_reasoning_content_error_does_not_downgrade( + nim_provider, +): + req = make_request() + error = _make_internal_server_error( + "reasoning_content could not be processed by the upstream model" + ) + + with patch.object( + nim_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.side_effect = error + + with pytest.raises(ExecutionFailure) as exc_info: + [e async for e in nim_provider.stream_response(req)] + + assert mock_create.await_count == 1 + assert "Provider API request failed" in exc_info.value.message diff --git a/tests/providers/test_nvidia_nim_degraded_retry.py b/tests/providers/test_nvidia_nim_degraded_retry.py new file mode 100644 index 0000000..55d3537 --- /dev/null +++ b/tests/providers/test_nvidia_nim_degraded_retry.py @@ -0,0 +1,287 @@ +"""NVIDIA Cloud Function deployment failures use provider-owned retry semantics.""" + +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import httpx +import openai +import pytest + +from free_claude_code.config.nim import NimSettings +from free_claude_code.core.failures import ExecutionFailure, FailureKind +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.failure_policy import ( + overloaded_provider_failure, + retryable_upstream_status, +) +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from free_claude_code.providers.open_router import OpenRouterProvider +from free_claude_code.providers.rate_limit import ( + DEFAULT_UPSTREAM_MAX_RETRIES, + UPSTREAM_TRANSIENT_TOTAL_ATTEMPTS, + ProviderRateLimiter, +) +from tests.providers.request_factory import make_messages_request + +_FUNCTION_ID = "87ea0ddc-cff1-4bca-bf8b-3bd98a35ddd0" +_DEGRADED_DETAIL = f"Function id '{_FUNCTION_ID}': DEGRADED function cannot be invoked" + + +def _config(base_url: str) -> ProviderConfig: + return ProviderConfig( + api_key="test_key", + base_url=base_url, + rate_limit=1_000_000, + rate_window=1, + max_concurrency=1_000, + http_read_timeout=30.0, + http_write_timeout=15.0, + http_connect_timeout=5.0, + ) + + +def _limiter() -> ProviderRateLimiter: + return ProviderRateLimiter( + rate_limit=1_000_000, + rate_window=1.0, + max_concurrency=1_000, + ) + + +def _bad_request( + detail: str = _DEGRADED_DETAIL, + *, + body_extra: dict[str, str] | None = None, +) -> openai.BadRequestError: + request = httpx.Request( + "POST", "https://integrate.api.nvidia.com/v1/chat/completions" + ) + response = httpx.Response(400, request=request) + body: dict[str, object] = { + "status": 400, + "title": "Bad Request", + "detail": detail, + } + if body_extra is not None: + body.update(body_extra) + return openai.BadRequestError("Bad Request", response=response, body=body) + + +def _successful_stream(text: str = "Recovered"): + chunk = MagicMock() + chunk.choices = [ + MagicMock( + delta=MagicMock(content=text, reasoning_content=""), + finish_reason="stop", + ) + ] + chunk.usage = None + + async def stream(): + yield chunk + + return stream() + + +def _nim(limiter: ProviderRateLimiter) -> NvidiaNimProvider: + return NvidiaNimProvider( + _config("https://integrate.api.nvidia.com/v1"), + nim_settings=NimSettings(), + rate_limiter=limiter, + ) + + +@pytest.mark.asyncio +async def test_degraded_function_retries_unchanged_request_then_succeeds() -> None: + limiter = _limiter() + provider = _nim(limiter) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=[_bad_request(), _successful_stream()], + ) as create, + patch.object(limiter, "extend_reactive_block") as extend_block, + patch( + "free_claude_code.providers.rate_limit.asyncio.sleep", + new_callable=AsyncMock, + ) as sleep, + ): + events = [ + event + async for event in provider.stream_response( + make_messages_request(), request_id="req_recovered" + ) + ] + + assert create.await_count == 2 + assert create.call_args_list[0].kwargs == create.call_args_list[1].kwargs + extend_block.assert_called_once() + sleep.assert_awaited_once() + event_text = "".join(events) + assert "Recovered" in event_text + assert "event: message_stop" in event_text + assert "event: error" not in event_text + + +@pytest.mark.asyncio +async def test_degraded_function_exhaustion_is_detailed_redacted_overload() -> None: + limiter = _limiter() + provider = _nim(limiter) + error = _bad_request( + body_extra={ + "authorization": "Bearer NIM_AUTH_SECRET", + "api_key": "NIM_API_SECRET", + } + ) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=error, + ) as create, + patch.object(limiter, "extend_reactive_block") as extend_block, + patch( + "free_claude_code.providers.rate_limit.asyncio.sleep", + new_callable=AsyncMock, + ) as sleep, + patch("free_claude_code.providers.openai_chat.provider.trace_event") as trace, + pytest.raises(ExecutionFailure) as exc_info, + ): + [ + event + async for event in provider.stream_response( + make_messages_request(), request_id="req_degraded" + ) + ] + + assert create.await_count == UPSTREAM_TRANSIENT_TOTAL_ATTEMPTS + assert extend_block.call_count == DEFAULT_UPSTREAM_MAX_RETRIES + assert sleep.await_count == DEFAULT_UPSTREAM_MAX_RETRIES + + failure = exc_info.value + assert failure.kind is FailureKind.OVERLOADED + assert failure.status_code == 529 + assert failure.retryable is True + assert "Upstream provider NIM returned HTTP 400." in failure.message + assert _DEGRADED_DETAIL in failure.message + assert "Request ID: req_degraded" in failure.message + assert "NIM_AUTH_SECRET" not in failure.message + assert "NIM_API_SECRET" not in failure.message + + error_traces = [ + call.kwargs + for call in trace.call_args_list + if call.kwargs.get("event") == "provider.response.error" + ] + assert error_traces[-1]["exc_type"] == "BadRequestError" + assert error_traces[-1]["failure_kind"] == "overloaded" + assert error_traces[-1]["status_code"] == 529 + assert error_traces[-1]["provider_retryable"] is True + assert "error_message" not in error_traces[-1] + + +@pytest.mark.parametrize( + "detail", + [ + "Unsupported field: top_k", + "Validation failed: DEGRADED function cannot be invoked", + f"Function id '{_FUNCTION_ID}': DEGRADING function cannot be invoked", + f"Function id '{_FUNCTION_ID}': DEGRADED function is waiting", + ], +) +@pytest.mark.asyncio +async def test_unrelated_nim_bad_request_is_not_retried(detail: str) -> None: + limiter = _limiter() + provider = _nim(limiter) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=_bad_request(detail), + ) as create, + patch.object(limiter, "extend_reactive_block") as extend_block, + patch( + "free_claude_code.providers.rate_limit.asyncio.sleep", + new_callable=AsyncMock, + ) as sleep, + pytest.raises(ExecutionFailure) as exc_info, + ): + [event async for event in provider.stream_response(make_messages_request())] + + assert create.await_count == 1 + extend_block.assert_not_called() + sleep.assert_not_awaited() + assert exc_info.value.kind is FailureKind.INVALID_REQUEST + assert exc_info.value.status_code == 400 + assert exc_info.value.retryable is False + + +@pytest.mark.asyncio +async def test_degraded_wording_remains_non_retryable_for_other_providers() -> None: + limiter = _limiter() + provider = OpenRouterProvider( + _config("https://openrouter.ai/api/v1"), rate_limiter=limiter + ) + error = _bad_request() + + assert retryable_upstream_status(error) is None + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=error, + ) as create, + patch.object(limiter, "extend_reactive_block") as extend_block, + patch( + "free_claude_code.providers.rate_limit.asyncio.sleep", + new_callable=AsyncMock, + ) as sleep, + pytest.raises(ExecutionFailure) as exc_info, + ): + [event async for event in provider.stream_response(make_messages_request())] + + assert create.await_count == 1 + extend_block.assert_not_called() + sleep.assert_not_awaited() + assert exc_info.value.kind is FailureKind.INVALID_REQUEST + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_limiter_override_preserves_raw_exception_after_exhaustion() -> None: + limiter = _limiter() + errors = (_bad_request(), _bad_request()) + attempts = 0 + + async def fail() -> None: + nonlocal attempts + error = errors[attempts] + attempts += 1 + raise error + + override = Mock(return_value=overloaded_provider_failure()) + with ( + patch.object(limiter, "extend_reactive_block"), + patch( + "free_claude_code.providers.rate_limit.asyncio.sleep", + new_callable=AsyncMock, + ), + pytest.raises(openai.BadRequestError) as exc_info, + ): + await limiter.execute_with_retry( + fail, + provider_failure_override=override, + max_retries=1, + ) + + assert attempts == 2 + assert override.call_count == 2 + assert exc_info.value is errors[-1] diff --git a/tests/providers/test_nvidia_nim_request.py b/tests/providers/test_nvidia_nim_request.py new file mode 100644 index 0000000..3fed380 --- /dev/null +++ b/tests/providers/test_nvidia_nim_request.py @@ -0,0 +1,541 @@ +"""Tests for NVIDIA NIM request policy helpers.""" + +from copy import deepcopy +from typing import Any + +import pytest + +from free_claude_code.config.nim import NimSettings +from free_claude_code.core.anthropic import set_if_not_none +from free_claude_code.core.anthropic.models import MessagesRequest, Tool +from free_claude_code.providers.nvidia_nim.request_options import ( + _set_extra, +) +from free_claude_code.providers.nvidia_nim.request_options import ( + build_nim_request_body as build_request_body, +) +from free_claude_code.providers.nvidia_nim.retry import ( + clone_body_without_chat_template, + clone_body_without_reasoning_content, +) +from free_claude_code.providers.nvidia_nim.tool_schema import ( + NIM_TOOL_ARGUMENT_ALIASES_KEY, + body_without_nim_tool_argument_aliases, + nim_tool_argument_aliases_from_body, +) +from tests.providers.request_factory import make_messages_request + +GREP_SCHEMA_FROM_SERVER_LOG: dict[str, Any] = { + "type": "object", + "properties": { + "pattern": {"type": "string", "description": "The regular expression"}, + "path": {"type": "string", "description": "File or directory to search"}, + "glob": {"type": "string", "description": "Glob to filter files"}, + "output_mode": { + "type": "string", + "enum": ["content", "files_with_matches", "count"], + }, + "-A": {"type": "number", "description": "Lines after match"}, + "-B": {"type": "number", "description": "Lines before match"}, + "-C": {"type": "number", "description": "Lines around match"}, + "-i": {"type": "boolean", "description": "Case insensitive"}, + "-n": {"type": "boolean", "description": "Show line numbers"}, + "type": {"type": "string", "description": "File type to search"}, + }, + "additionalProperties": False, + "required": ["pattern"], +} + + +@pytest.fixture +def req() -> MessagesRequest: + return make_messages_request( + model="test", + messages=[{"role": "user", "content": "hi"}], + max_tokens=100, + system=None, + temperature=None, + top_p=None, + stop_sequences=None, + tools=None, + extra_body=None, + top_k=None, + thinking=None, + ) + + +class TestSetIfNotNone: + def test_value_not_none_sets(self): + body = {} + set_if_not_none(body, "key", "value") + assert body["key"] == "value" + + def test_value_none_skips(self): + body = {} + set_if_not_none(body, "key", None) + assert "key" not in body + + +class TestSetExtra: + def test_key_in_extra_body_skips(self): + extra = {"top_k": 42} + _set_extra(extra, "top_k", 10) + assert extra["top_k"] == 42 + + def test_value_none_skips(self): + extra = {} + _set_extra(extra, "top_k", None) + assert "top_k" not in extra + + def test_value_equals_ignore_value_skips(self): + extra = {} + _set_extra(extra, "top_k", -1, ignore_value=-1) + assert "top_k" not in extra + + def test_value_set_when_valid(self): + extra = {} + _set_extra(extra, "top_k", 10, ignore_value=-1) + assert extra["top_k"] == 10 + + +class TestBuildRequestBody: + def test_max_tokens_capped_by_nim(self, req): + req.max_tokens = 100000 + nim = NimSettings(max_tokens=4096) + body = build_request_body(req, nim, thinking_enabled=True) + assert body["max_tokens"] == 4096 + + def test_presence_penalty_included_when_nonzero(self, req): + nim = NimSettings(presence_penalty=0.5) + body = build_request_body(req, nim, thinking_enabled=True) + assert body["presence_penalty"] == 0.5 + + def test_include_stop_str_in_output_not_sent(self, req): + body = build_request_body(req, NimSettings(), thinking_enabled=True) + assert "include_stop_str_in_output" not in body.get("extra_body", {}) + + def test_parallel_tool_calls_included(self, req): + nim = NimSettings(parallel_tool_calls=False) + body = build_request_body(req, nim, thinking_enabled=True) + assert body["parallel_tool_calls"] is False + + def test_tool_schema_boolean_subschemas_are_removed_without_mutating_request( + self, req + ): + tool_schema = { + "type": "object", + "properties": { + "query": {"type": "string", "default": False}, + "blocked": False, + "nested": {"type": "object", "additionalProperties": False}, + "choice": {"anyOf": [False, {"type": "string"}]}, + }, + "additionalProperties": False, + "required": ["query"], + } + req.tools = [ + Tool( + name="search", + description="search", + input_schema=tool_schema, + ) + ] + + body = build_request_body(req, NimSettings(), thinking_enabled=False) + + parameters = body["tools"][0]["function"]["parameters"] + properties = parameters["properties"] + assert "additionalProperties" not in parameters + assert "blocked" not in properties + assert "additionalProperties" not in properties["nested"] + assert properties["choice"]["anyOf"] == [{"type": "string"}] + assert properties["query"]["default"] is False + assert tool_schema["additionalProperties"] is False + assert tool_schema["properties"]["nested"]["additionalProperties"] is False + + def test_grep_schema_type_parameter_is_aliased_without_mutating_request(self, req): + tool_schema = deepcopy(GREP_SCHEMA_FROM_SERVER_LOG) + tool_schema["properties"]["_fcc_arg_type"] = { + "type": "string", + "description": "Existing safe property that collides with the alias", + } + tool_schema["required"] = ["pattern", "-A", "_fcc_arg_type"] + original_schema = deepcopy(tool_schema) + req.tools = [ + Tool( + name="Grep", + description="Search file contents", + input_schema=tool_schema, + ) + ] + + body = build_request_body(req, NimSettings(), thinking_enabled=False) + + parameters = body["tools"][0]["function"]["parameters"] + properties = parameters["properties"] + aliases = body[NIM_TOOL_ARGUMENT_ALIASES_KEY]["Grep"] + assert "additionalProperties" not in parameters + assert properties["-A"] == original_schema["properties"]["-A"] + assert properties["-B"] == original_schema["properties"]["-B"] + assert properties["-C"] == original_schema["properties"]["-C"] + assert properties["-i"] == original_schema["properties"]["-i"] + assert properties["-n"] == original_schema["properties"]["-n"] + assert "type" not in properties + assert properties["pattern"] == original_schema["properties"]["pattern"] + assert properties["output_mode"]["enum"] == [ + "content", + "files_with_matches", + "count", + ] + assert ( + properties["_fcc_arg_type"] + == original_schema["properties"]["_fcc_arg_type"] + ) + assert aliases == {"_fcc_arg_type_2": "type"} + assert properties["_fcc_arg_type_2"] == original_schema["properties"]["type"] + assert "-A" in parameters["required"] + assert "_fcc_arg_type" in parameters["required"] + assert tool_schema == original_schema + + def test_safe_tool_schema_does_not_add_alias_metadata(self, req): + tool_schema = { + "type": "object", + "properties": { + "pattern": {"type": "string"}, + "path": {"type": "string"}, + "output_mode": {"type": "string", "enum": ["content", "count"]}, + }, + "required": ["pattern"], + } + req.tools = [ + Tool( + name="Glob", + description="Find files", + input_schema=tool_schema, + ) + ] + + body = build_request_body(req, NimSettings(), thinking_enabled=False) + + assert NIM_TOOL_ARGUMENT_ALIASES_KEY not in body + parameters = body["tools"][0]["function"]["parameters"] + assert parameters["properties"] == tool_schema["properties"] + assert parameters["required"] == ["pattern"] + + def test_nested_schema_keyword_properties_are_aliased_without_mutating_request( + self, req + ): + tool_schema = { + "type": "object", + "properties": { + "parent": { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["page_id"]}, + "id": {"type": "string"}, + }, + "required": ["type", "id"], + } + }, + "required": ["parent"], + } + original_schema = deepcopy(tool_schema) + req.tools = [ + Tool( + name="NotionLike", + description="Nested type schema", + input_schema=tool_schema, + ) + ] + + body = build_request_body(req, NimSettings(), thinking_enabled=False) + + aliases = body[NIM_TOOL_ARGUMENT_ALIASES_KEY]["NotionLike"] + parent = body["tools"][0]["function"]["parameters"]["properties"]["parent"] + parent_properties = parent["properties"] + assert "type" not in parent_properties + assert parent_properties["_fcc_arg_type"] == { + "type": "string", + "enum": ["page_id"], + } + assert parent["required"] == ["_fcc_arg_type", "id"] + assert aliases == {"_fcc_arg_type": "type"} + assert tool_schema == original_schema + + def test_private_alias_metadata_is_stripped_without_mutating_body(self): + body = { + "model": "test", + NIM_TOOL_ARGUMENT_ALIASES_KEY: {"Grep": {"_fcc_arg_A": "-A"}}, + } + + upstream_body = body_without_nim_tool_argument_aliases(body) + + assert NIM_TOOL_ARGUMENT_ALIASES_KEY not in upstream_body + assert body[NIM_TOOL_ARGUMENT_ALIASES_KEY] == {"Grep": {"_fcc_arg_A": "-A"}} + assert nim_tool_argument_aliases_from_body(body) == { + "Grep": {"_fcc_arg_A": "-A"} + } + + def test_reasoning_params_in_extra_body(self): + req = make_messages_request( + model="test", + messages=[{"role": "user", "content": "hi"}], + max_tokens=100, + system=None, + temperature=None, + top_p=None, + stop_sequences=None, + tools=None, + tool_choice=None, + extra_body=None, + top_k=None, + thinking=None, + ) + + nim = NimSettings() + body = build_request_body(req, nim, thinking_enabled=True) + extra = body["extra_body"] + assert extra["chat_template_kwargs"] == { + "thinking": True, + "enable_thinking": True, + "reasoning_budget": body["max_tokens"], + } + assert "reasoning_budget" not in extra + + def test_clone_body_without_chat_template(self): + body = { + "model": "test", + "extra_body": { + "chat_template": "custom_template", + "chat_template_kwargs": { + "thinking": True, + "enable_thinking": True, + "reasoning_budget": 100, + }, + "ignore_eos": False, + }, + } + + cloned = clone_body_without_chat_template(body) + + assert cloned is not None + assert "chat_template" not in cloned["extra_body"] + assert "chat_template_kwargs" not in cloned["extra_body"] + assert cloned["extra_body"]["ignore_eos"] is False + assert body["extra_body"]["chat_template"] == "custom_template" + assert body["extra_body"]["chat_template_kwargs"] == { + "thinking": True, + "enable_thinking": True, + "reasoning_budget": 100, + } + + def test_clone_body_without_chat_template_kwargs_only(self): + body = { + "model": "test", + "extra_body": { + "chat_template_kwargs": { + "thinking": True, + "enable_thinking": True, + "reasoning_budget": 100, + }, + "ignore_eos": False, + }, + } + + cloned = clone_body_without_chat_template(body) + + assert cloned is not None + assert "chat_template" not in cloned["extra_body"] + assert "chat_template_kwargs" not in cloned["extra_body"] + assert cloned["extra_body"]["ignore_eos"] is False + + def test_clone_body_without_chat_template_returns_none_when_unchanged(self): + body = {"model": "test", "extra_body": {"ignore_eos": False}} + + assert clone_body_without_chat_template(body) is None + + def test_no_chat_template_kwargs_when_thinking_disabled(self): + req = make_messages_request( + model="test", + messages=[{"role": "user", "content": "hi"}], + max_tokens=100, + system=None, + temperature=None, + top_p=None, + stop_sequences=None, + tools=None, + tool_choice=None, + extra_body=None, + top_k=None, + thinking=None, + ) + + nim = NimSettings() + body = build_request_body(req, nim, thinking_enabled=False) + extra = body.get("extra_body", {}) + assert "chat_template_kwargs" not in extra + assert "reasoning_budget" not in extra + + def test_reasoning_budget_respects_existing_chat_template_kwargs(self): + req = make_messages_request( + model="test", + messages=[{"role": "user", "content": "hi"}], + max_tokens=100, + system=None, + temperature=None, + top_p=None, + stop_sequences=None, + tools=None, + tool_choice=None, + top_k=None, + extra_body={ + "chat_template_kwargs": { + "enable_thinking": False, + "custom": "value", + } + }, + thinking=None, + ) + + body = build_request_body(req, NimSettings(), thinking_enabled=True) + assert body["extra_body"]["chat_template_kwargs"] == { + "enable_thinking": False, + "custom": "value", + "reasoning_budget": body["max_tokens"], + } + + def test_chat_template_fields_present_for_mistral_model(self): + req = make_messages_request( + model="mistralai/mixtral-8x7b-instruct-v0.1", + messages=[{"role": "user", "content": "hi"}], + max_tokens=100, + system=None, + temperature=None, + top_p=None, + stop_sequences=None, + tools=None, + tool_choice=None, + extra_body=None, + top_k=None, + thinking=None, + ) + + nim = NimSettings(chat_template="custom_template") + body = build_request_body(req, nim, thinking_enabled=True) + extra = body.get("extra_body", {}) + assert extra["chat_template_kwargs"] == { + "thinking": True, + "enable_thinking": True, + "reasoning_budget": body["max_tokens"], + } + assert extra["chat_template"] == "custom_template" + + def test_no_reasoning_params_in_extra_body(self): + req = make_messages_request( + model="test", + messages=[{"role": "user", "content": "hi"}], + max_tokens=100, + system=None, + temperature=None, + top_p=None, + stop_sequences=None, + tools=None, + tool_choice=None, + extra_body=None, + top_k=None, + thinking=None, + ) + + nim = NimSettings() + body = build_request_body(req, nim, thinking_enabled=False) + extra = body.get("extra_body", {}) + for param in ( + "thinking", + "reasoning_split", + "return_tokens_as_token_ids", + "include_reasoning", + "reasoning_effort", + ): + assert param not in extra + + def test_assistant_thinking_blocks_removed_when_disabled(self): + req = make_messages_request( + model="test", + messages=[ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "secret"}, + {"type": "text", "text": "answer"}, + ], + } + ], + max_tokens=100, + system=None, + temperature=None, + top_p=None, + stop_sequences=None, + tools=None, + tool_choice=None, + extra_body=None, + top_k=None, + thinking=None, + ) + + body = build_request_body(req, NimSettings(), thinking_enabled=False) + assert "" not in body["messages"][0]["content"] + assert "answer" in body["messages"][0]["content"] + + def test_assistant_thinking_replayed_as_reasoning_content_when_enabled(self): + req = make_messages_request( + model="test", + messages=[ + { + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "secret"}, + {"type": "text", "text": "answer"}, + ], + } + ], + max_tokens=100, + system=None, + temperature=None, + top_p=None, + stop_sequences=None, + tools=None, + tool_choice=None, + extra_body=None, + top_k=None, + thinking=None, + ) + + body = build_request_body(req, NimSettings(), thinking_enabled=True) + assistant = body["messages"][0] + assert assistant["reasoning_content"] == "secret" + assert assistant["content"] == "answer" + assert "" not in assistant["content"] + + def test_clone_body_without_reasoning_content(self): + body = { + "model": "test", + "messages": [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "answer", + "reasoning_content": "secret", + }, + ], + } + + cloned = clone_body_without_reasoning_content(body) + + assert cloned is not None + assert "reasoning_content" not in cloned["messages"][1] + assert body["messages"][1]["reasoning_content"] == "secret" + + def test_clone_body_without_reasoning_content_returns_none_when_unchanged(self): + body = {"model": "test", "messages": [{"role": "user", "content": "hi"}]} + + assert clone_body_without_reasoning_content(body) is None diff --git a/tests/providers/test_ollama.py b/tests/providers/test_ollama.py new file mode 100644 index 0000000..9153c41 --- /dev/null +++ b/tests/providers/test_ollama.py @@ -0,0 +1,91 @@ +"""Tests for the Ollama OpenAI-compatible provider.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import OLLAMA_DEFAULT_BASE +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + +OLLAMA_MODEL = "llama3.1:8b" + + +def _provider(base_url: str = OLLAMA_DEFAULT_BASE) -> OpenAIChatProvider: + return profiled_provider( + "ollama", + ProviderConfig(api_key="ollama", base_url=base_url), + rate_limiter=passthrough_rate_limiter(), + ) + + +@pytest.mark.parametrize( + ("configured", "expected"), + [ + ("http://localhost:11434", "http://localhost:11434/v1"), + ("http://localhost:11434/", "http://localhost:11434/v1"), + ("http://localhost:11434/v1", "http://localhost:11434/v1"), + ], +) +def test_init_normalizes_openai_base_url(configured: str, expected: str) -> None: + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as openai_client: + provider = _provider(configured) + + assert provider._provider_name == "OLLAMA" + assert provider._base_url == expected + assert provider._api_key == "ollama" + assert openai_client.call_args.kwargs["base_url"] == expected + + +def test_build_request_body_uses_openai_chat_shape() -> None: + body = _provider()._build_request_body(make_messages_request(OLLAMA_MODEL)) + + assert body["model"] == OLLAMA_MODEL + assert body["messages"][0]["role"] == "system" + assert "thinking" not in body + assert "extra_body" not in body + + +@pytest.mark.asyncio +async def test_stream_response_uses_shared_openai_chat_provider() -> None: + provider = _provider() + chunk = MagicMock() + chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello from Ollama", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + chunk.usage = MagicMock(prompt_tokens=8, completion_tokens=4) + + async def stream(): + yield chunk + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream(), + ) as create: + output = "".join( + [ + event + async for event in provider.stream_response( + make_messages_request(OLLAMA_MODEL) + ) + ] + ) + + assert create.call_args.kwargs["stream"] is True + assert create.call_args.kwargs["model"] == OLLAMA_MODEL + assert "Hello from Ollama" in output + assert parse_sse_text(output)[-1].event == "message_stop" diff --git a/tests/providers/test_open_router.py b/tests/providers/test_open_router.py new file mode 100644 index 0000000..7e484ca --- /dev/null +++ b/tests/providers/test_open_router.py @@ -0,0 +1,225 @@ +"""Tests for the OpenRouter OpenAI-chat provider.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.anthropic.stream_contracts import ( + parse_sse_text, + text_content, +) +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.open_router import OpenRouterProvider +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +class AsyncStream: + def __init__(self, chunks): + self._chunks = chunks + self.closed = False + + def __aiter__(self): + return self._iter() + + async def _iter(self): + for chunk in self._chunks: + yield chunk + + async def aclose(self): + self.closed = True + + +def make_request(**overrides): + return make_messages_request("moonshotai/kimi-k2.6:free", **overrides) + + +@pytest.fixture +def open_router_provider(): + return OpenRouterProvider( + ProviderConfig( + api_key="test_openrouter_key", + base_url="https://openrouter.ai/api/v1", + rate_limit=10, + rate_window=60, + ), + rate_limiter=passthrough_rate_limiter(), + ) + + +def _chunk( + *, + content: str | None = None, + reasoning_content: str | None = None, + reasoning_details: list[dict] | None = None, + finish_reason: str | None = None, +): + delta = SimpleNamespace( + content=content, + reasoning_content=reasoning_content, + tool_calls=None, + ) + if reasoning_details is not None: + delta.reasoning_details = reasoning_details + choice = SimpleNamespace(delta=delta, finish_reason=finish_reason) + return SimpleNamespace(choices=[choice], usage=None) + + +def test_init_uses_openai_chat_provider(open_router_provider): + assert isinstance(open_router_provider, OpenAIChatProvider) + assert open_router_provider._api_key == "test_openrouter_key" + assert open_router_provider._base_url == "https://openrouter.ai/api/v1" + + +def test_build_request_body_uses_openai_chat_shape(open_router_provider): + body = open_router_provider._build_request_body(make_request()) + + assert body["model"] == "moonshotai/kimi-k2.6:free" + assert body["temperature"] == 0.5 + assert body["messages"] == [ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "Hello"}, + ] + assert body["max_tokens"] == 100 + assert body["extra_body"]["reasoning"] == {"enabled": True} + + +def test_build_request_body_default_max_tokens(open_router_provider): + body = open_router_provider._build_request_body(make_request(max_tokens=None)) + + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + +def test_openrouter_extra_body_rejects_overriding_reserved_fields( + open_router_provider, +): + with pytest.raises(InvalidRequestError, match="model"): + open_router_provider._build_request_body( + make_request(extra_body={"model": "hijack"}) + ) + + +def test_openrouter_extra_body_allows_provider_keys(open_router_provider): + body = open_router_provider._build_request_body( + make_request(extra_body={"transforms": ["no-web"], "plugins": []}), + thinking_enabled=False, + ) + + assert body["extra_body"] == {"transforms": ["no-web"], "plugins": []} + + +def test_build_request_body_omits_reasoning_when_thinking_disabled( + open_router_provider, +): + body = open_router_provider._build_request_body( + make_request(thinking={"type": "disabled"}) + ) + + assert "extra_body" not in body + + +def test_build_request_body_maps_thinking_budget_to_reasoning_max_tokens( + open_router_provider, +): + body = open_router_provider._build_request_body( + make_request(thinking={"type": "enabled", "budget_tokens": 4096}) + ) + + assert body["extra_body"]["reasoning"] == {"enabled": True, "max_tokens": 4096} + + +def test_build_request_body_replays_openrouter_reasoning_details( + open_router_provider, +): + detail = {"type": "reasoning.encrypted", "data": "opaque"} + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "redacted_thinking", + "data": '{"type":"reasoning.encrypted","data":"opaque"}', + }, + {"type": "text", "text": "Need a tool."}, + ], + }, + {"role": "user", "content": "continue"}, + ], + } + ) + + body = open_router_provider._build_request_body(request) + + assistant = next(msg for msg in body["messages"] if msg["role"] == "assistant") + assert assistant["reasoning_details"] == [detail] + + +@pytest.mark.asyncio +async def test_stream_maps_reasoning_content_and_details(open_router_provider): + redacted = {"type": "reasoning.encrypted", "data": "opaque"} + stream = AsyncStream( + [ + _chunk(reasoning_content="plan "), + _chunk(reasoning_details=[redacted]), + _chunk(content="done", finish_reason="stop"), + ] + ) + with patch.object( + open_router_provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream, + ): + events = [ + event + async for event in open_router_provider.stream_response(make_request()) + ] + + event_text = "".join(events) + assert "thinking_delta" in event_text + assert "plan " in event_text + assert "redacted_thinking" in event_text + assert "opaque" in event_text + assert "done" in text_content(parse_sse_text(event_text)) + assert stream.closed + + +@pytest.mark.asyncio +async def test_model_infos_filter_tool_models_and_thinking_metadata( + open_router_provider, +): + open_router_provider._client.models.list = AsyncMock( + return_value=SimpleNamespace( + data=[ + SimpleNamespace( + id="tool-model", + supported_parameters=["tools", "reasoning"], + ), + SimpleNamespace(id="plain-model", supported_parameters=[]), + ] + ) + ) + + infos = await open_router_provider.list_model_infos() + + assert {(info.model_id, info.supports_thinking) for info in infos} == { + ("tool-model", True) + } + + +@pytest.mark.asyncio +async def test_cleanup_closes_openai_client(open_router_provider): + open_router_provider._client = MagicMock() + open_router_provider._client.close = AsyncMock() + + await open_router_provider.cleanup() + + open_router_provider._client.close.assert_awaited_once() diff --git a/tests/providers/test_openai_chat_output_cap.py b/tests/providers/test_openai_chat_output_cap.py new file mode 100644 index 0000000..9170330 --- /dev/null +++ b/tests/providers/test_openai_chat_output_cap.py @@ -0,0 +1,191 @@ +"""Tests for OpenAI-compatible output-token cap recovery (issue #955). + +Covers the pure parse/clamp helpers and the provider behavior that clamps +``max_completion_tokens``/``max_tokens`` to the upstream maximum, retries once, +and learns the cap so later requests clamp proactively. +""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import GROQ_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat.output_cap import ( + clamp_output_tokens, + parse_output_token_cap, +) +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +class _BadRequest(Exception): + """Stand-in for openai.BadRequestError (status_code + optional JSON body).""" + + def __init__(self, message: str, body: object | None = None): + super().__init__(message) + self.status_code = 400 + self.body = body + + +# --------------------------------------------------------------------------- # +# Pure helpers +# --------------------------------------------------------------------------- # + + +def test_parse_cap_from_groq_message(): + error = _BadRequest( + "max_completion_tokens must be less than or equal to 40960, the maximum " + "value for max_completion_tokens is less than the context_window for this model" + ) + assert parse_output_token_cap(error) == 40960 + + +@pytest.mark.parametrize( + "message,expected", + [ + ("max_tokens: maximum value is 8192", 8192), + ("max_tokens must not exceed 16000", 16000), + ("`max_completion_tokens` <= 4096 required", 4096), + ("max_tokens at most 2048 allowed", 2048), + ("maximum allowed value of 32768 for max_tokens", 32768), + ], +) +def test_parse_cap_various_phrasings(message, expected): + assert parse_output_token_cap(_BadRequest(message)) == expected + + +def test_parse_cap_reads_json_body(): + error = _BadRequest( + "invalid request", + body={"error": {"param": "max_completion_tokens", "message": "<= 12000"}}, + ) + assert parse_output_token_cap(error) == 12000 + + +def test_parse_cap_ignores_non_400(): + error = _BadRequest("max_tokens must be less than or equal to 40960") + error.status_code = 500 + assert parse_output_token_cap(error) is None + + +def test_parse_cap_ignores_unrelated_400(): + assert parse_output_token_cap(_BadRequest("temperature must be <= 2")) is None + + +def test_parse_cap_returns_none_without_number(): + assert ( + parse_output_token_cap(_BadRequest("max_tokens is larger than allowed")) is None + ) + + +def test_clamp_reduces_max_completion_tokens(): + assert clamp_output_tokens({"max_completion_tokens": 64000}, 40960) == { + "max_completion_tokens": 40960 + } + + +def test_clamp_reduces_max_tokens(): + assert clamp_output_tokens({"max_tokens": 100000}, 8192) == {"max_tokens": 8192} + + +def test_clamp_noop_when_within_cap_returns_none(): + assert clamp_output_tokens({"max_completion_tokens": 1000}, 40960) is None + + +def test_clamp_does_not_mutate_input(): + body = {"max_tokens": 99999, "model": "m"} + clamped = clamp_output_tokens(body, 8192) + assert body["max_tokens"] == 99999 + assert clamped is not None + assert clamped["max_tokens"] == 8192 + + +def test_clamp_ignores_bool_values(): + assert clamp_output_tokens({"max_tokens": True}, 8192) is None + + +# --------------------------------------------------------------------------- # +# Provider integration (via Groq's profile, which uses max_completion_tokens) +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def groq_provider(): + return profiled_provider( + "groq", + ProviderConfig( + api_key="test_groq_key", + base_url=GROQ_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=False, + ), + rate_limiter=passthrough_rate_limiter(), + ) + + +@pytest.mark.asyncio +async def test_create_stream_clamps_and_learns_on_cap_rejection(groq_provider): + body = groq_provider._build_request_body( + make_messages_request( + "llama-3.3-70b-versatile", + max_tokens=64000, + thinking={"enabled": False}, + ) + ) + assert body["max_completion_tokens"] == 64000 + model = body["model"] + + error = _BadRequest("max_completion_tokens must be less than or equal to 40960") + create = AsyncMock(side_effect=[error, object()]) + + with patch.object(groq_provider._client.chat.completions, "create", create): + _stream, used_body = await groq_provider._create_stream(body) + + assert create.call_count == 2 + assert create.call_args_list[1].kwargs["max_completion_tokens"] == 40960 + assert used_body["max_completion_tokens"] == 40960 + assert groq_provider._model_output_caps[model] == 40960 + + +@pytest.mark.asyncio +async def test_learned_cap_clamps_next_request_without_a_400(groq_provider): + body = groq_provider._build_request_body( + make_messages_request( + "llama-3.3-70b-versatile", + max_tokens=64000, + thinking={"enabled": False}, + ) + ) + model = body["model"] + groq_provider._model_output_caps[model] = 40960 + + create = AsyncMock(return_value=object()) + with patch.object(groq_provider._client.chat.completions, "create", create): + _stream, used_body = await groq_provider._create_stream(body) + + assert create.call_count == 1 + assert create.call_args.kwargs["max_completion_tokens"] == 40960 + assert used_body["max_completion_tokens"] == 40960 + + +@pytest.mark.asyncio +async def test_unrelated_400_is_not_clamped_and_propagates(groq_provider): + body = groq_provider._build_request_body( + make_messages_request( + "llama-3.3-70b-versatile", + max_tokens=100, + thinking={"enabled": False}, + ) + ) + create = AsyncMock(side_effect=_BadRequest("messages: invalid role 'wizard'")) + + with ( + patch.object(groq_provider._client.chat.completions, "create", create), + pytest.raises(Exception, match="wizard"), + ): + await groq_provider._create_stream(body) + + assert create.call_count == 1 + assert groq_provider._model_output_caps == {} diff --git a/tests/providers/test_openai_chat_usage.py b/tests/providers/test_openai_chat_usage.py new file mode 100644 index 0000000..b05a12e --- /dev/null +++ b/tests/providers/test_openai_chat_usage.py @@ -0,0 +1,214 @@ +"""OpenAI-chat streamed usage helper tests.""" + +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, patch + +import openai +import pytest +from httpx import Request, Response + +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.core.anthropic.stream_contracts import parse_sse_text +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import ( + OpenAIChatProfile, + OpenAIChatProvider, + OpenAIChatRequestPolicy, +) +from free_claude_code.providers.openai_chat.usage import ( + clone_without_stream_usage, + is_stream_usage_rejection, + request_stream_usage, + usage_int, +) +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +class _UsageTestProvider(OpenAIChatProvider): + def __init__(self): + super().__init__( + ProviderConfig( + api_key="test_key", + base_url="https://provider.example/v1", + rate_limit=100, + rate_window=60, + ), + profile=OpenAIChatProfile( + OpenAIChatRequestPolicy(provider_name="USAGE_TEST") + ), + rate_limiter=passthrough_rate_limiter(), + ) + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + return {"model": request.model, "messages": [{"role": "user", "content": "x"}]} + + +def _bad_request(message: str, body: object | None = None) -> openai.BadRequestError: + response = Response( + 400, + request=Request("POST", "https://provider.example/v1/chat/completions"), + ) + return openai.BadRequestError(message, response=response, body=body) + + +async def _stream(chunks): + for chunk in chunks: + yield chunk + + +def _chunk( + *, + content: str | None = None, + finish_reason: str | None = None, + usage: Any = None, +): + if content is None and finish_reason is None: + return SimpleNamespace(choices=[], usage=usage) + return SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace( + content=content, + reasoning_content=None, + tool_calls=None, + ), + finish_reason=finish_reason, + ) + ], + usage=usage, + ) + + +def test_request_stream_usage_adds_stream_options_when_absent(): + body = {"model": "m"} + + request_stream_usage(body) + + assert body["stream_options"] == {"include_usage": True} + + +def test_request_stream_usage_preserves_existing_stream_options(): + stream_options = {"foo": "bar"} + body = {"model": "m", "stream_options": stream_options} + + request_stream_usage(body) + + assert body["stream_options"] == {"foo": "bar", "include_usage": True} + assert body["stream_options"] is stream_options + + +def test_clone_without_stream_usage_removes_only_include_usage(): + body = { + "model": "m", + "stream_options": {"foo": "bar", "include_usage": True}, + } + + retry_body = clone_without_stream_usage(body) + + assert retry_body == {"model": "m", "stream_options": {"foo": "bar"}} + assert body["stream_options"] == {"foo": "bar", "include_usage": True} + + +def test_clone_without_stream_usage_drops_empty_stream_options(): + body = {"model": "m", "stream_options": {"include_usage": True}} + + retry_body = clone_without_stream_usage(body) + + assert retry_body == {"model": "m"} + + +def test_usage_int_reads_dict_object_and_model_extra(): + assert usage_int({"prompt_tokens": 11}, "prompt_tokens") == 11 + assert usage_int(SimpleNamespace(completion_tokens=7), "completion_tokens") == 7 + assert ( + usage_int( + SimpleNamespace(model_extra={"prompt_cache_hit_tokens": 3}), + "prompt_cache_hit_tokens", + ) + == 3 + ) + assert usage_int(SimpleNamespace(prompt_tokens=None), "prompt_tokens") is None + assert usage_int({"prompt_tokens": True}, "prompt_tokens") is None + + +def test_stream_usage_rejection_matches_usage_option_400(): + error = _bad_request( + "Unrecognized request argument supplied: stream_options", + {"error": {"message": "stream_options is unsupported"}}, + ) + + assert is_stream_usage_rejection(error) + + +def test_stream_usage_rejection_does_not_match_unrelated_400(): + error = _bad_request( + "messages: invalid role", + {"error": {"message": "messages contains invalid role"}}, + ) + + assert not is_stream_usage_rejection(error) + + +@pytest.mark.asyncio +async def test_openai_chat_stream_requests_usage_and_uses_provider_prompt_tokens(): + provider = _UsageTestProvider() + request = make_messages_request(model="m") + usage = SimpleNamespace(prompt_tokens=22, completion_tokens=4) + create = AsyncMock( + return_value=_stream( + [ + _chunk(content="hello"), + _chunk(finish_reason="stop"), + _chunk(usage=usage), + ] + ) + ) + + with patch.object(provider._client.chat.completions, "create", create): + events = [ + event async for event in provider.stream_response(request, input_tokens=7) + ] + + create.assert_awaited_once() + await_args = create.await_args + assert await_args is not None + assert await_args.kwargs["stream_options"] == {"include_usage": True} + parsed = parse_sse_text("".join(events)) + start_usage = next( + event.data["message"]["usage"] + for event in parsed + if event.event == "message_start" + ) + final_usage = next( + event.data["usage"] for event in parsed if event.event == "message_delta" + ) + assert start_usage["input_tokens"] == 7 + assert final_usage == {"input_tokens": 22, "output_tokens": 4} + + +@pytest.mark.asyncio +async def test_openai_chat_stream_retries_without_usage_when_option_is_rejected(): + provider = _UsageTestProvider() + body = {"model": "m", "messages": [{"role": "user", "content": "x"}]} + request_stream_usage(body) + create = AsyncMock( + side_effect=[ + _bad_request( + "stream_options is unsupported", + {"error": {"message": "stream_options is unsupported"}}, + ), + object(), + ] + ) + + with patch.object(provider._client.chat.completions, "create", create): + _stream_obj, used_body = await provider._create_stream(body) + + assert create.await_count == 2 + assert create.await_args_list[0].kwargs["stream_options"] == {"include_usage": True} + assert "stream_options" not in create.await_args_list[1].kwargs + assert "stream_options" not in used_body diff --git a/tests/providers/test_openai_compat_5xx_retry.py b/tests/providers/test_openai_compat_5xx_retry.py new file mode 100644 index 0000000..af8fff0 --- /dev/null +++ b/tests/providers/test_openai_compat_5xx_retry.py @@ -0,0 +1,208 @@ +"""OpenAI-chat providers route upstream 5xx and 429 through the same retry path.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import openai +import pytest +from httpx import Request, Response + +from free_claude_code.config.nim import NimSettings +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from tests.providers.request_factory import make_messages_request +from tests.providers.support import retrying_rate_limiter + + +def _internal_5xx(code: int) -> openai.InternalServerError: + return openai.InternalServerError( + "unavailable", + response=Response(code, request=Request("POST", "http://x")), + body={}, + ) + + +def _connection_error(message: str = "connect failed") -> openai.APIConnectionError: + error = openai.APIConnectionError( + request=Request("POST", "https://test.api.nvidia.com/v1/chat/completions") + ) + error.__cause__ = httpx.ConnectError(message) + return error + + +@pytest.mark.parametrize("status_code", [500, 502, 503, 504]) +@pytest.mark.asyncio +async def test_nim_stream_retries_on_openai_5xx_then_streams(status_code): + config = ProviderConfig( + api_key="test_key", + base_url="https://test.api.nvidia.com/v1", + rate_limit=100, + rate_window=60, + http_read_timeout=600.0, + http_write_timeout=15.0, + http_connect_timeout=5.0, + ) + provider = NvidiaNimProvider( + config, + nim_settings=NimSettings(), + rate_limiter=retrying_rate_limiter(), + ) + req = make_messages_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="Hi", reasoning_content=""), + finish_reason="stop", + ) + ] + mock_chunk.usage = None + + async def mock_stream(): + yield mock_chunk + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + ) as mock_create, + ): + mock_create.side_effect = [_internal_5xx(status_code), mock_stream()] + events = [e async for e in provider.stream_response(req)] + + assert mock_create.await_count == 2 + assert any("Hi" in e for e in events) + + +@pytest.mark.asyncio +async def test_nim_stream_retries_on_pre_stream_connection_error_then_streams(): + config = ProviderConfig( + api_key="test_key", + base_url="https://test.api.nvidia.com/v1", + rate_limit=100, + rate_window=60, + http_read_timeout=600.0, + http_write_timeout=15.0, + http_connect_timeout=5.0, + ) + provider = NvidiaNimProvider( + config, + nim_settings=NimSettings(), + rate_limiter=retrying_rate_limiter(), + ) + req = make_messages_request() + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content="Recovered", reasoning_content=""), + finish_reason="stop", + ) + ] + mock_chunk.usage = None + + async def mock_stream(): + yield mock_chunk + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + ) as mock_create, + ): + mock_create.side_effect = [_connection_error(), mock_stream()] + events = [e async for e in provider.stream_response(req)] + + assert mock_create.await_count == 2 + assert any("Recovered" in e for e in events) + + +@pytest.mark.asyncio +async def test_nim_stream_connection_error_exhausted_emits_cause_chain(): + config = ProviderConfig( + api_key="test_key", + base_url="https://test.api.nvidia.com/v1", + rate_limit=100, + rate_window=60, + http_read_timeout=600.0, + http_write_timeout=15.0, + http_connect_timeout=5.0, + ) + provider = NvidiaNimProvider( + config, + nim_settings=NimSettings(), + rate_limiter=retrying_rate_limiter(), + ) + req = make_messages_request() + error = _connection_error("upstream disconnected") + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=error, + ) as mock_create, + patch("free_claude_code.providers.openai_chat.provider.trace_event") as trace, + pytest.raises(ExecutionFailure) as exc_info, + ): + [e async for e in provider.stream_response(req, request_id="req_conn")] + + assert mock_create.await_count == 5 + error_traces = [ + call.kwargs + for call in trace.call_args_list + if call.kwargs.get("event") == "provider.response.error" + ] + assert error_traces[-1]["request_id"] == "req_conn" + assert error_traces[-1]["exc_type"] == "APIConnectionError" + assert "error_message" not in error_traces[-1] + assert "Caused by:\nConnectError: upstream disconnected" in exc_info.value.message + + +@pytest.mark.parametrize( + ("status_code", "expect_substr"), + [ + (500, "provider api request failed"), + (502, "temporarily unavailable"), + (503, "temporarily unavailable"), + (504, "temporarily unavailable"), + ], +) +@pytest.mark.asyncio +async def test_nim_stream_openai_5xx_exhausted_emits_user_message( + status_code, + expect_substr, +): + config = ProviderConfig( + api_key="test_key", + base_url="https://test.api.nvidia.com/v1", + rate_limit=100, + rate_window=60, + http_read_timeout=600.0, + http_write_timeout=15.0, + http_connect_timeout=5.0, + ) + provider = NvidiaNimProvider( + config, + nim_settings=NimSettings(), + rate_limiter=retrying_rate_limiter(), + ) + req = make_messages_request() + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + ) as mock_create, + ): + mock_create.side_effect = _internal_5xx(status_code) + with pytest.raises(ExecutionFailure) as exc_info: + [e async for e in provider.stream_response(req)] + + assert mock_create.await_count == 5 + assert expect_substr in exc_info.value.message.lower() diff --git a/tests/providers/test_opencode.py b/tests/providers/test_opencode.py new file mode 100644 index 0000000..2b8ff76 --- /dev/null +++ b/tests/providers/test_opencode.py @@ -0,0 +1,40 @@ +"""Tests for the OpenCode OpenAI-compatible provider.""" + +from free_claude_code.core.anthropic.models import MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def test_build_request_body_preserves_empty_reasoning_content() -> None: + provider = profiled_provider( + "opencode", + ProviderConfig( + api_key="test_opencode_key", + base_url="https://example.invalid/v1", + rate_limit=1, + rate_window=1, + enable_thinking=True, + ), + rate_limiter=passthrough_rate_limiter(), + ) + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [ + { + "role": "assistant", + "content": "visible", + "reasoning_content": "", + } + ], + "thinking": {"type": "enabled"}, + } + ) + + body = provider._build_request_body(request) + + assert body["messages"][0] == { + "role": "assistant", + "content": "visible", + "reasoning_content": "", + } diff --git a/tests/providers/test_parsers.py b/tests/providers/test_parsers.py new file mode 100644 index 0000000..c382ccf --- /dev/null +++ b/tests/providers/test_parsers.py @@ -0,0 +1,514 @@ +import pytest + +from free_claude_code.core.anthropic import ( + ContentType, + HeuristicToolParser, + ThinkTagParser, +) + + +def test_think_tag_parser_basic(): + parser = ThinkTagParser() + chunks = list(parser.feed("Hello reasoning world")) + + assert len(chunks) == 3 + assert chunks[0].type == ContentType.TEXT + assert chunks[0].content == "Hello " + assert chunks[1].type == ContentType.THINKING + assert chunks[1].content == "reasoning" + assert chunks[2].type == ContentType.TEXT + assert chunks[2].content == " world" + + +def test_think_tag_parser_streaming(): + parser = ThinkTagParser() + + # Partial tag + chunks = list(parser.feed("Hello reasoning")) + assert len(chunks) == 1 + assert chunks[0].type == ContentType.THINKING + assert chunks[0].content == "reasoning" + + +def test_heuristic_tool_parser_basic(): + parser = HeuristicToolParser() + text = "Let's call a tool. ● hello." + filtered, tools_initial = parser.feed(text) + tools_final = parser.flush() + tools = tools_initial + tools_final + + assert "Let's call a tool." in filtered + assert len(tools) == 1 + assert tools[0]["name"] == "Grep" + assert tools[0]["input"] == {"pattern": "hello", "path": "."} + + +def test_heuristic_tool_parser_streaming(): + parser = HeuristicToolParser() + + # Feed part 1 + _filtered1, tools1 = parser.feed("● ") + assert tools1 == [] + + # Feed part 2 + _filtered2, tools2 = parser.feed("test.txt") + assert tools2 == [] + + # Feed part 3 (triggering flush or completion) + filtered3, tools3 = parser.feed("\nDone.") + assert len(tools3) == 1 + assert tools3[0]["name"] == "Write" + assert tools3[0]["input"] == {"path": "test.txt"} + assert "Done." in filtered3 + + +def test_heuristic_tool_parser_flush(): + parser = HeuristicToolParser() + parser.feed("● ls -la") + tools = parser.flush() + + assert len(tools) == 1 + assert tools[0]["name"] == "Bash" + assert tools[0]["input"] == {"command": "ls -la"} + + +def test_heuristic_tool_parser_strips_control_tokens(): + p = HeuristicToolParser() + filtered, tools = p.feed("Hello <|tool_call_end|> world") + tools.extend(p.flush()) + + assert "<|tool_call_end|>" not in filtered + assert filtered == "Hello world" + assert tools == [] + + +def test_heuristic_tool_parser_strips_control_tokens_split_across_chunks(): + p = HeuristicToolParser() + f1, t1 = p.feed("Hello <|tool_call_") + f2, t2 = p.feed("end|> world") + tools = t1 + t2 + p.flush() + + assert "<|tool_call_end|>" not in (f1 + f2) + assert (f1 + f2) == "Hello world" + assert tools == [] + + +def test_heuristic_tool_parser_strips_control_tokens_inside_tool_text(): + p = HeuristicToolParser() + text = ( + "Before <|tool_calls_section_end|> ● " + "hi After" + ) + filtered, tools = p.feed(text) + tools.extend(p.flush()) + + assert "<|tool_calls_section_end|>" not in filtered + assert "Before" in filtered + assert "After" in filtered + assert len(tools) == 1 + assert tools[0]["name"] == "Grep" + assert tools[0]["input"] == {"pattern": "hi"} + + +def test_interleaved_thinking_and_tools(): + parser_think = ThinkTagParser() + parser_tool = HeuristicToolParser() + + text = "I need to search for a file.test" + + # 1. Parse thinking + chunks = list(parser_think.feed(text)) + thinking = [c for c in chunks if c.type == ContentType.THINKING] + text_remaining = "".join([c.content for c in chunks if c.type == ContentType.TEXT]) + + assert len(thinking) == 1 + assert thinking[0].content == "I need to search for a file." + + # 2. Parse tool from remaining text + _filtered, tools = parser_tool.feed(text_remaining) + tools += parser_tool.flush() + + assert len(tools) == 1 + assert tools[0]["name"] == "Grep" + assert tools[0]["input"] == {"pattern": "test"} + + +def test_partial_interleaved_streaming(): + parser_think = ThinkTagParser() + parser_tool = HeuristicToolParser() + + # Chunk 1: Partial thinking (it emits since it's definitely not the start of ) + chunks1 = list(parser_think.feed("Part 1")) + assert len(chunks1) == 1 + assert chunks1[0].type == ContentType.THINKING + assert chunks1[0].content == "Part 1" + + # Chunk 2: Thinking ends, tool starts + chunks2 = list(parser_think.feed(" endstest.py")) + text_rem3 = "".join([c.content for c in chunks3]) + _filtered3, tools3 = parser_tool.feed(text_rem3) + tools3 += parser_tool.flush() + + assert len(tools3) == 1 + assert tools3[0]["name"] == "Read" + assert tools3[0]["input"] == {"path": "test.py"} + + +# --- New Robustness Tests --- + + +def test_split_across_markers(): + # Split across the trigger chaaracter + # "● " + # Split at various points + full_text = "● val" + + for i in range(len(full_text)): + p = HeuristicToolParser() + chunk1 = full_text[:i] + chunk2 = full_text[i:] + + tools = [] + _filtered, t = p.feed(chunk1) + tools.extend(t) + _filtered2, t = p.feed(chunk2) + tools.extend(t) + tools.extend(p.flush()) + + if len(tools) != 1: + print(f"Failed split at index {i}: '{chunk1}' | '{chunk2}'") + + assert len(tools) == 1, f"Failed split at index {i}" + assert tools[0]["name"] == "Test" + assert tools[0]["input"] == {"arg": "val"} + + +def test_value_with_special_chars(): + parser = HeuristicToolParser() + # Value with > inside + text = "● a > b" + _, tools = parser.feed(text) + tools.extend(parser.flush()) + + assert len(tools) == 1 + assert tools[0]["input"]["arg"] == "a > b" + + +def test_multiple_params_split(): + full_text = ( + "● v1v2" + ) + + for i in range(len(full_text)): + p = HeuristicToolParser() + tools = [] + _, t = p.feed(full_text[:i]) + tools.extend(t) + _, t = p.feed(full_text[i:]) + tools.extend(t) + tools.extend(p.flush()) + + assert len(tools) == 1, f"Failed split at {i}" + assert tools[0]["input"] == {"p1": "v1", "p2": "v2"} + + +def test_incomplete_tag_flush(): + p = HeuristicToolParser() + p.feed("● hello") + tools = p.flush() + + assert len(tools) == 1 + assert tools[0]["input"]["msg"] == "hello" + + +def test_garbage_interleaved(): + p = HeuristicToolParser() + tools = [] + _, t = p.feed("Some text ") + tools.extend(t) + _, t = p.feed("● 1") + tools.extend(t) + _, t = p.feed(" more text ") + tools.extend(t) + _, t = p.feed("● 2") + tools.extend(t) + tools.extend(p.flush()) + + assert len(tools) == 2 + assert tools[0]["name"] == "T1" + assert tools[1]["name"] == "T2" + + +def test_text_between_params_lost(): + p = HeuristicToolParser() + # " text1 " is between function end and first param + # " text2 " is between params + text = "● text1 1 text2 2" + filtered, tools = p.feed(text) + tools.extend(p.flush()) + + # Check if "text1" and "text2" are preserved in filtered output + assert "text1" in filtered + assert "text2" in filtered + assert tools[0]["input"] == {"a": "1", "b": "2"} + + +# --- Orphan Tag Tests (Step Fun AI compatibility) --- + + +def test_orphan_close_tag_stripped(): + """Orphan without opening tag should be stripped.""" + parser = ThinkTagParser() + chunks = list(parser.feed("Hello world")) + + # Should get one text chunk with orphan tag stripped + assert len(chunks) == 2 + assert chunks[0].type == ContentType.TEXT + assert chunks[0].content == "Hello " + assert chunks[1].type == ContentType.TEXT + assert chunks[1].content == " world" + + +def test_orphan_close_tag_at_start(): + """Orphan at start should be stripped.""" + parser = ThinkTagParser() + chunks = list(parser.feed("Hello world")) + + assert len(chunks) == 1 + assert chunks[0].type == ContentType.TEXT + assert chunks[0].content == "Hello world" + + +def test_orphan_close_tag_at_end(): + """Orphan at end should be stripped.""" + parser = ThinkTagParser() + chunks = list(parser.feed("Hello world")) + + assert len(chunks) == 1 + assert chunks[0].type == ContentType.TEXT + assert chunks[0].content == "Hello world" + + +def test_multiple_orphan_close_tags(): + """Multiple orphan tags should all be stripped.""" + parser = ThinkTagParser() + chunks = list(parser.feed("abc")) + + text = "".join(c.content for c in chunks if c.type == ContentType.TEXT) + assert text == "abc" + assert "" not in text + + +def test_orphan_close_tag_streaming(): + """Orphan split across chunks should be stripped.""" + parser = ThinkTagParser() + + # Feed partial orphan tag + chunks1 = list(parser.feed("Hello world")) + assert len(chunks2) == 1 + assert chunks2[0].type == ContentType.TEXT + assert chunks2[0].content == " world" + + +def test_orphan_close_with_valid_think_pair(): + """Orphan followed by valid ... pair.""" + parser = ThinkTagParser() + chunks = list(parser.feed("abthinkingc")) + + types = [c.type for c in chunks] + # contents = [c.content for c in chunks] # Unused + + assert ContentType.TEXT in types + assert ContentType.THINKING in types + # Text should be "ab" and "c", thinking should be "thinking" + text_content = "".join(c.content for c in chunks if c.type == ContentType.TEXT) + think_content = "".join(c.content for c in chunks if c.type == ContentType.THINKING) + assert text_content == "abc" + assert think_content == "thinking" + + +# --- Parametrized Edge Case Tests --- + + +@pytest.mark.parametrize( + "input_text,expected_text", + [ + ("Hello world", "Hello world"), + ("Hello world", "Hello world"), + ("Hello world", "Hello world"), + ("abc", "abc"), + ("", ""), + ("", ""), + ], + ids=[ + "middle", + "start", + "end", + "multiple", + "only_orphan", + "consecutive_orphans", + ], +) +def test_orphan_close_tag_parametrized(input_text, expected_text): + """Parametrized: orphan tags should be stripped from various positions.""" + parser = ThinkTagParser() + chunks = list(parser.feed(input_text)) + text = "".join(c.content for c in chunks if c.type == ContentType.TEXT) + assert text == expected_text + assert "" not in text + + +def test_think_tag_parser_empty_input(): + """Empty string input should yield no chunks.""" + parser = ThinkTagParser() + chunks = list(parser.feed("")) + assert chunks == [] + + +def test_think_tag_parser_flush_no_content(): + """Flush with no buffered content should return None.""" + parser = ThinkTagParser() + result = parser.flush() + assert result is None + + +def test_think_tag_parser_flush_buffered_text(): + """Flush with buffered text returns TEXT chunk.""" + parser = ThinkTagParser() + # Feed partial tag that stays buffered + list(parser.feed("Hello with buffered partial close tag returns THINKING chunk.""" + parser = ThinkTagParser() + # Feed content that ends with a potential partial tag, which stays buffered + chunks = list(parser.feed("partial reasoning pair should yield no thinking content.""" + parser = ThinkTagParser() + chunks = list(parser.feed("remaining")) + # Empty think yields nothing for thinking, just the remaining text + # types = [c.type for c in chunks] # Unused + text = "".join(c.content for c in chunks if c.type == ContentType.TEXT) + assert text == "remaining" + + +def test_think_tag_parser_unicode(): + """Unicode content inside and outside think tags.""" + parser = ThinkTagParser() + chunks = list(parser.feed("日本語 思考中 🤔 結果")) + thinking = "".join(c.content for c in chunks if c.type == ContentType.THINKING) + text = "".join(c.content for c in chunks if c.type == ContentType.TEXT) + assert thinking == "思考中 🤔" + assert "日本語" in text + assert "結果" in text + + +def test_heuristic_tool_parser_empty_input(): + """Empty string input should return empty filtered text and no tools.""" + parser = HeuristicToolParser() + filtered, tools = parser.feed("") + assert filtered == "" + assert tools == [] + + +def test_heuristic_tool_parser_flush_no_tool(): + """Flush when no tool is being parsed should return empty list.""" + parser = HeuristicToolParser() + parser.feed("plain text") + tools = parser.flush() + assert tools == [] + + +def test_heuristic_tool_parser_json_style_web_fetch_tool_call(): + parser = HeuristicToolParser() + text = ( + "Use WebFetch on the article.\n\n" + "{\n" + ' "url": "https://example.com/article",\n' + ' "prompt": "Summarize it."\n' + "}\n" + ) + + filtered, tools = parser.feed(text) + tools.extend(parser.flush()) + + assert filtered == "" + assert len(tools) == 1 + assert tools[0]["name"] == "WebFetch" + assert tools[0]["input"] == { + "url": "https://example.com/article", + "prompt": "Summarize it.", + } + + +def test_heuristic_tool_parser_json_style_web_search_tool_call(): + parser = HeuristicToolParser() + + filtered, tools = parser.feed('Use WebSearch {"query": "DeepSeek V4"}') + tools.extend(parser.flush()) + + assert filtered == "" + assert len(tools) == 1 + assert tools[0]["name"] == "WebSearch" + assert tools[0]["input"] == {"query": "DeepSeek V4"} + + +def test_heuristic_tool_parser_unicode_function_name(): + """Unicode characters in function parameters.""" + parser = HeuristicToolParser() + text = "● 日本語テスト" + _filtered, tools = parser.feed(text) + tools.extend(parser.flush()) + assert len(tools) == 1 + assert tools[0]["name"] == "Search" + assert tools[0]["input"]["query"] == "日本語テスト" + + +@pytest.mark.parametrize( + "malformed_text", + [ + "● ", + "● v", + ], + ids=["empty_name", "empty_name_with_param"], +) +def test_heuristic_tool_parser_malformed_function_tag(malformed_text): + """Malformed function tags should still be handled without crashing.""" + parser = HeuristicToolParser() + _filtered, tools = parser.feed(malformed_text) + tools.extend(parser.flush()) + # Should not crash; may or may not detect a tool depending on regex match diff --git a/tests/providers/test_preflight_contract.py b/tests/providers/test_preflight_contract.py new file mode 100644 index 0000000..764d279 --- /dev/null +++ b/tests/providers/test_preflight_contract.py @@ -0,0 +1,62 @@ +"""The shared OpenAI-chat provider owns explicit request preflight.""" + +from collections.abc import AsyncIterator + +import pytest + +from free_claude_code.core.anthropic.models import Message, MessagesRequest +from free_claude_code.providers.base import BaseProvider, ProviderConfig +from free_claude_code.providers.openai_chat import OpenAIChatProvider + + +class RecordingOpenAIProvider(OpenAIChatProvider): + def __init__(self) -> None: + self.build_calls: list[tuple[MessagesRequest, bool | None]] = [] + + def _build_request_body( + self, request: MessagesRequest, thinking_enabled: bool | None = None + ) -> dict: + self.build_calls.append((request, thinking_enabled)) + return {} + + +class ProviderWithoutPreflight(BaseProvider): + async def cleanup(self) -> None: + return None + + async def list_model_ids(self) -> frozenset[str]: + return frozenset() + + async def stream_response( + self, + request: MessagesRequest, + input_tokens: int = 0, + *, + request_id: str | None = None, + thinking_enabled: bool | None = None, + ) -> AsyncIterator[str]: + if False: + yield "" + + +def test_provider_base_requires_an_explicit_preflight_implementation() -> None: + with pytest.raises(TypeError, match="preflight_stream"): + ProviderWithoutPreflight( + ProviderConfig(api_key="test", base_url="https://test.invalid") + ) + + +def test_openai_provider_owns_preflight() -> None: + assert OpenAIChatProvider.preflight_stream is not BaseProvider.preflight_stream + + +def test_provider_preflight_calls_builder_and_preserves_false() -> None: + provider = RecordingOpenAIProvider() + request = MessagesRequest( + model="test-model", + messages=[Message(role="user", content="hello")], + ) + + provider.preflight_stream(request, thinking_enabled=False) + + assert provider.build_calls == [(request, False)] diff --git a/tests/providers/test_provider_rate_limit.py b/tests/providers/test_provider_rate_limit.py new file mode 100644 index 0000000..61c6cc4 --- /dev/null +++ b/tests/providers/test_provider_rate_limit.py @@ -0,0 +1,753 @@ +import asyncio +import time +from unittest.mock import AsyncMock, patch + +import httpx +import openai +import pytest +from httpx import Request + +import free_claude_code.providers.rate_limit as rate_limit_module +from free_claude_code.providers.failure_policy import ( + retryable_upstream_status, + retryable_upstream_transport_error, +) +from free_claude_code.providers.rate_limit import ( + DEFAULT_UPSTREAM_MAX_RETRIES, + UPSTREAM_TRANSIENT_TOTAL_ATTEMPTS, + ProviderRateLimiter, +) + + +def test_upstream_transient_retry_total_attempts_is_five() -> None: + assert UPSTREAM_TRANSIENT_TOTAL_ATTEMPTS == 5 + assert DEFAULT_UPSTREAM_MAX_RETRIES == 4 + + +def _statusless_api_error(message: str, body: object | None) -> openai.APIError: + return openai.APIError(message, request=Request("POST", "http://x"), body=body) + + +def test_retryable_upstream_status_reads_statusless_api_error_body_status() -> None: + exc = _statusless_api_error( + "stream embedded error", + {"error": {"message": "internal failure", "code": 500}}, + ) + + assert retryable_upstream_status(exc) == 500 + + +def test_retryable_upstream_status_reads_statusless_resource_exhausted_text() -> None: + exc = _statusless_api_error( + "ResourceExhausted: limit reached while generating response", + {"error": {"message": "ResourceExhausted: limit reached"}}, + ) + + assert retryable_upstream_status(exc) == 503 + + +def test_retryable_upstream_transport_error_classifies_connection_failures() -> None: + request = Request("POST", "http://x") + assert retryable_upstream_transport_error( + openai.APIConnectionError(request=request) + ) + assert retryable_upstream_transport_error(httpx.ConnectError("connect failed")) + assert retryable_upstream_transport_error(httpx.ReadError("read failed")) + assert retryable_upstream_transport_error(httpx.WriteError("write failed")) + assert retryable_upstream_transport_error( + httpx.RemoteProtocolError("server disconnected") + ) + assert retryable_upstream_transport_error(TimeoutError("timed out")) + + +def test_retryable_upstream_transport_error_rejects_request_errors() -> None: + from httpx import Response + + request = Request("POST", "http://x") + assert not retryable_upstream_transport_error( + openai.BadRequestError( + "bad request", + response=Response(400, request=request), + body={}, + ) + ) + assert not retryable_upstream_transport_error( + httpx.HTTPStatusError( + "bad request", + request=request, + response=Response(400, request=request), + ) + ) + + +class TestProviderRateLimiter: + """Tests for providers.rate_limit.ProviderRateLimiter.""" + + @pytest.mark.asyncio + async def test_proactive_throttling(self): + """ + Test proactive throttling. + Logic ported from verify_provider_limiter.py + """ + # Re-init with tight limits: 1 request per 0.25 second + limiter = ProviderRateLimiter(rate_limit=1, rate_window=0.25) + + start_time = time.monotonic() + + async def call_limiter(): + await limiter.wait_if_blocked() + return time.monotonic() + + # 5 requests. + # R0 -> 0s + # R1 -> 0.25s + # R2 -> 0.50s + # R3 -> 0.75s + # R4 -> 1.00s + results = [await call_limiter() for _ in range(5)] + + total_time = time.monotonic() - start_time + + assert len(results) == 5 + # Should take at least ~1.0s + assert total_time >= 0.9, f"Throttling failed, took too fast: {total_time:.2f}s" + + @pytest.mark.asyncio + async def test_reactive_blocking(self): + """ + Test reactive blocking when a deadline is extended. + Logic ported from verify_provider_limiter.py + """ + limiter = ProviderRateLimiter() + + start_time = time.monotonic() + + # Manually block for 1.5s + block_time = 1.5 + limiter.extend_reactive_block(block_time) + + assert limiter.is_blocked() + + async def call_limiter(): + return await limiter.wait_if_blocked() + + # Run 2 calls concurrently + # They should both wait for the block time + results = await asyncio.gather(call_limiter(), call_limiter()) + + total_time = time.monotonic() - start_time + + # Both should report having waited reactively + assert all(results) is True + assert total_time >= block_time - 0.1, ( + f"Reactive block failed, took {total_time:.2f}s" + ) + + @pytest.mark.asyncio + async def test_reactive_block_at_proactive_commit_retries_without_reserving( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + proactive_attempts = 0 + proactive_reservations = 0 + reactive_checks = 0 + + async def acquire_if_allowed(_allowed) -> bool: + nonlocal proactive_attempts, proactive_reservations + proactive_attempts += 1 + if proactive_attempts == 1: + return False + proactive_reservations += 1 + return True + + async def wait_reactively() -> bool: + nonlocal reactive_checks + reactive_checks += 1 + return reactive_checks == 2 + + monkeypatch.setattr( + limiter._proactive_limiter, + "acquire_if", + acquire_if_allowed, + ) + monkeypatch.setattr( + limiter, + "_wait_for_reactive_block", + wait_reactively, + ) + + assert await limiter.wait_if_blocked() is True + assert proactive_attempts == 2 + assert proactive_reservations == 1 + assert reactive_checks == 2 + + @pytest.mark.asyncio + async def test_reactive_wait_rechecks_an_extended_deadline( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + now = 0.0 + sleep_delays: list[float] = [] + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + async def advance_time(delay: float) -> None: + nonlocal now + sleep_delays.append(delay) + if len(sleep_delays) == 1: + now = 5.0 + limiter.extend_reactive_block(10.0) + now = 10.0 + return + now += delay + + monkeypatch.setattr(rate_limit_module.time, "monotonic", lambda: now) + monkeypatch.setattr(rate_limit_module.asyncio, "sleep", advance_time) + monkeypatch.setattr( + limiter._proactive_limiter, + "acquire_if", + AsyncMock(return_value=True), + ) + limiter.extend_reactive_block(10.0) + + assert await limiter.wait_if_blocked() is True + assert sleep_delays == [10.0, 5.0] + assert limiter.is_blocked() is False + + @pytest.mark.asyncio + async def test_reactive_wait_propagates_cancellation( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + sleep_started = asyncio.Event() + + async def wait_forever(_delay: float) -> None: + sleep_started.set() + await asyncio.Event().wait() + + monkeypatch.setattr(rate_limit_module.asyncio, "sleep", wait_forever) + limiter.extend_reactive_block(60.0) + waiter = asyncio.create_task(limiter.wait_if_blocked()) + await sleep_started.wait() + + waiter.cancel() + + with pytest.raises(asyncio.CancelledError): + await waiter + assert limiter.is_blocked() is True + + @pytest.mark.asyncio + async def test_proactive_wait_propagates_cancellation( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + acquire_started = asyncio.Event() + + async def wait_forever(_allowed) -> bool: + acquire_started.set() + await asyncio.Event().wait() + return True + + monkeypatch.setattr(limiter._proactive_limiter, "acquire_if", wait_forever) + waiter = asyncio.create_task(limiter.wait_if_blocked()) + await acquire_started.wait() + + waiter.cancel() + + with pytest.raises(asyncio.CancelledError): + await waiter + + def test_shorter_reactive_backoff_does_not_shorten_existing_deadline( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + now = 100.0 + monkeypatch.setattr(rate_limit_module.time, "monotonic", lambda: now) + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + limiter.extend_reactive_block(10.0) + now = 102.0 + limiter.extend_reactive_block(1.0) + + assert limiter.remaining_wait() == 8.0 + + @pytest.mark.parametrize("seconds", [0.0, -1.0]) + def test_reactive_backoff_duration_must_be_positive(self, seconds: float) -> None: + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + with pytest.raises(ValueError, match="reactive block duration must be > 0"): + limiter.extend_reactive_block(seconds) + + @pytest.mark.asyncio + async def test_remaining_wait_when_not_blocked(self): + """remaining_wait() should return 0 when not blocked.""" + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + assert limiter.remaining_wait() == 0 + + @pytest.mark.asyncio + async def test_remaining_wait_decreases(self): + """remaining_wait() should decrease over time.""" + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + limiter.extend_reactive_block(2.0) + + wait1 = limiter.remaining_wait() + assert wait1 > 1.5 + + await asyncio.sleep(0.5) + wait2 = limiter.remaining_wait() + assert wait2 < wait1 + + @pytest.mark.asyncio + async def test_is_blocked_false_initially(self): + """is_blocked() should be False for a fresh limiter.""" + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + assert limiter.is_blocked() is False + + @pytest.mark.asyncio + async def test_high_rate_limit_no_throttling(self): + """Very high rate limit should not cause throttling.""" + limiter = ProviderRateLimiter(rate_limit=10000, rate_window=60) + + start = time.monotonic() + for _ in range(20): + await limiter.wait_if_blocked() + duration = time.monotonic() - start + + # 20 requests with 10000 limit should be near-instant + assert duration < 1.0, f"High rate limit caused throttling: {duration:.2f}s" + + @pytest.mark.asyncio + async def test_instances_are_independent(self): + """Each provider limiter owns independent reactive state.""" + limiter1 = ProviderRateLimiter(rate_limit=10, rate_window=1) + limiter2 = ProviderRateLimiter(rate_limit=10, rate_window=1) + + limiter1.extend_reactive_block(1) + + assert limiter1 is not limiter2 + assert limiter1.is_blocked() is True + assert limiter2.is_blocked() is False + + @pytest.mark.asyncio + async def test_wait_if_blocked_returns_false_when_not_blocked(self): + """wait_if_blocked should return False when not reactively blocked.""" + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + result = await limiter.wait_if_blocked() + assert result is False + + @pytest.mark.asyncio + async def test_proactive_strict_rolling_window(self): + """ + Proactive limiter should enforce a strict rolling window: + for any i, t[i+rate_limit] - t[i] >= rate_window (within tolerance). + """ + rate_limit = 2 + rate_window = 0.5 + limiter = ProviderRateLimiter(rate_limit=rate_limit, rate_window=rate_window) + + acquired: list[float] = [] + + async def acquire(): + await limiter.wait_if_blocked() + acquired.append(time.monotonic()) + + # Trigger concurrency; without strict rolling windows, this can burst. + await asyncio.gather(*(acquire() for _ in range(5))) + + acquired.sort() + assert len(acquired) == 5 + + tolerance = 0.05 + for i in range(len(acquired) - rate_limit): + assert acquired[i + rate_limit] - acquired[i] >= rate_window - tolerance, ( + f"Rolling window violated at i={i}: " + f"dt={acquired[i + rate_limit] - acquired[i]:.3f}s" + ) + + @pytest.mark.asyncio + async def test_init_rate_limit_zero_raises(self): + """rate_limit <= 0 raises ValueError.""" + with pytest.raises(ValueError, match="rate_limit must be > 0"): + ProviderRateLimiter(rate_limit=0, rate_window=60) + + @pytest.mark.asyncio + async def test_init_rate_window_zero_raises(self): + """rate_window <= 0 raises ValueError.""" + with pytest.raises(ValueError, match="rate_window must be > 0"): + ProviderRateLimiter(rate_limit=10, rate_window=0) + + @pytest.mark.asyncio + async def test_execute_with_retry_exhaust_retries_raises(self): + """When all 429 retries exhausted, last exception is raised.""" + import openai + from httpx import Request, Response + + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + def make_429(): + return openai.RateLimitError( + "rate limited", + response=Response(429, request=Request("POST", "http://x")), + body={}, + ) + + async def fail(): + raise make_429() + + with pytest.raises(openai.RateLimitError): + await limiter.execute_with_retry( + fail, max_retries=2, base_delay=0.01, max_delay=0.1, jitter=0 + ) + + @pytest.mark.asyncio + async def test_execute_with_retry_succeeds_on_retry(self): + """429 then success returns result.""" + import openai + from httpx import Request, Response + + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + def make_429(): + return openai.RateLimitError( + "rate limited", + response=Response(429, request=Request("POST", "http://x")), + body={}, + ) + + call_count = 0 + + async def fail_then_ok(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise make_429() + return "ok" + + result = await limiter.execute_with_retry( + fail_then_ok, max_retries=2, base_delay=0.01, max_delay=0.1, jitter=0 + ) + assert result == "ok" + assert call_count == 2 + + @pytest.mark.asyncio + async def test_execute_with_retry_succeeds_on_httpx_429(self): + """HTTP 429 as httpx.HTTPStatusError then success returns result.""" + import httpx + from httpx import Request, Response + + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + call_count = 0 + + async def fail_then_ok(): + nonlocal call_count + call_count += 1 + if call_count == 1: + r = Response(429, request=Request("POST", "http://x"), text="slow") + raise httpx.HTTPStatusError( + "Too Many Requests", request=r.request, response=r + ) + return "ok" + + result = await limiter.execute_with_retry( + fail_then_ok, max_retries=2, base_delay=0.01, max_delay=0.1, jitter=0 + ) + assert result == "ok" + assert call_count == 2 + + @pytest.mark.parametrize("status_code", [500, 502, 503, 504]) + @pytest.mark.asyncio + async def test_execute_with_retry_succeeds_on_openai_internal_server_error_5xx( + self, status_code + ): + """5xx as openai.InternalServerError then success.""" + import openai + from httpx import Request, Response + + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + def make_upstream_error(): + return openai.InternalServerError( + "unavailable", + response=Response(status_code, request=Request("POST", "http://x")), + body={}, + ) + + call_count = 0 + + async def fail_then_ok(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise make_upstream_error() + return "ok" + + result = await limiter.execute_with_retry( + fail_then_ok, max_retries=2, base_delay=0.01, max_delay=0.1, jitter=0 + ) + assert result == "ok" + assert call_count == 2 + + @pytest.mark.parametrize("status_code", [500, 502, 503, 504]) + @pytest.mark.asyncio + async def test_execute_with_retry_succeeds_on_httpx_5xx(self, status_code): + """HTTP 5xx as httpx.HTTPStatusError then success.""" + import httpx + from httpx import Request, Response + + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + call_count = 0 + + async def fail_then_ok(): + nonlocal call_count + call_count += 1 + if call_count == 1: + r = Response( + status_code, request=Request("POST", "http://x"), text="error" + ) + raise httpx.HTTPStatusError( + "Server Error", request=r.request, response=r + ) + return "ok" + + result = await limiter.execute_with_retry( + fail_then_ok, max_retries=2, base_delay=0.01, max_delay=0.1, jitter=0 + ) + assert result == "ok" + assert call_count == 2 + + @pytest.mark.asyncio + async def test_execute_with_retry_succeeds_on_statusless_transient_api_error(self): + """Status-less SDK APIError transient markers participate in backoff retry.""" + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + call_count = 0 + + async def fail_then_ok(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise _statusless_api_error( + "ResourceExhausted: limit reached while generating response", + {"error": {"message": "ResourceExhausted: limit reached"}}, + ) + return "ok" + + result = await limiter.execute_with_retry( + fail_then_ok, max_retries=2, base_delay=0.01, max_delay=0.1, jitter=0 + ) + + assert result == "ok" + assert call_count == 2 + + @pytest.mark.asyncio + async def test_execute_with_retry_succeeds_on_openai_connection_retry(self): + """Pre-response OpenAI SDK connection errors retry then succeed.""" + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + call_count = 0 + request = Request("POST", "http://x") + + async def fail_then_ok(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise openai.APIConnectionError(request=request) + return "ok" + + with ( + patch.object(limiter, "extend_reactive_block") as extend_block, + patch("asyncio.sleep", new_callable=AsyncMock) as sleep, + ): + result = await limiter.execute_with_retry( + fail_then_ok, + max_retries=2, + base_delay=0.01, + max_delay=0.1, + jitter=0, + ) + + assert result == "ok" + assert call_count == 2 + extend_block.assert_not_called() + sleep.assert_awaited_once() + + @pytest.mark.asyncio + async def test_execute_with_retry_succeeds_on_httpx_transport_retry(self): + """Pre-response HTTPX transport errors retry then succeed.""" + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + call_count = 0 + + async def fail_then_ok(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise httpx.RemoteProtocolError("server disconnected") + return "ok" + + with ( + patch.object(limiter, "extend_reactive_block") as extend_block, + patch("asyncio.sleep", new_callable=AsyncMock) as sleep, + ): + result = await limiter.execute_with_retry( + fail_then_ok, + max_retries=2, + base_delay=0.01, + max_delay=0.1, + jitter=0, + ) + + assert result == "ok" + assert call_count == 2 + extend_block.assert_not_called() + sleep.assert_awaited_once() + + @pytest.mark.asyncio + async def test_execute_with_retry_exhaust_transport_error_attempts(self): + """Transport retries exhaust after the shared 5 total attempts.""" + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + call_count = 0 + + async def always_fail(): + nonlocal call_count + call_count += 1 + raise httpx.ConnectError("connect failed") + + with ( + patch.object(limiter, "extend_reactive_block") as extend_block, + patch("asyncio.sleep", new_callable=AsyncMock) as sleep, + pytest.raises(httpx.ConnectError), + ): + await limiter.execute_with_retry( + always_fail, + base_delay=0.01, + max_delay=0.1, + jitter=0, + ) + + assert call_count == UPSTREAM_TRANSIENT_TOTAL_ATTEMPTS + extend_block.assert_not_called() + assert sleep.await_count == DEFAULT_UPSTREAM_MAX_RETRIES + + @pytest.mark.parametrize("status_code", [500, 502, 503, 504]) + @pytest.mark.asyncio + async def test_execute_with_retry_exhaust_openai_5xx_raises(self, status_code): + """When all 5xx retries exhausted (OpenAI SDK), last InternalServerError is raised.""" + import openai + from httpx import Request, Response + + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + exc = openai.InternalServerError( + "unavailable", + response=Response(status_code, request=Request("POST", "http://x")), + body={}, + ) + + async def always_fail(): + raise exc + + with pytest.raises(openai.InternalServerError): + await limiter.execute_with_retry( + always_fail, max_retries=2, base_delay=0.01, max_delay=0.1, jitter=0 + ) + + @pytest.mark.asyncio + async def test_execute_with_retry_httpx_400_raises_immediately(self): + """Non-retryable 4xx is not wrapped by execute_with_retry loop.""" + import httpx + from httpx import Request, Response + + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60) + + call_count = 0 + + async def bad_request(): + nonlocal call_count + call_count += 1 + r = Response(400, request=Request("POST", "http://x"), text="bad request") + raise httpx.HTTPStatusError("Bad Request", request=r.request, response=r) + + with pytest.raises(httpx.HTTPStatusError): + await limiter.execute_with_retry( + bad_request, + max_retries=2, + base_delay=0.01, + max_delay=0.1, + jitter=0, + ) + + assert call_count == 1 + + @pytest.mark.asyncio + async def test_max_concurrency_zero_raises(self): + """max_concurrency <= 0 raises ValueError.""" + with pytest.raises(ValueError, match="max_concurrency must be > 0"): + ProviderRateLimiter(rate_limit=10, rate_window=60, max_concurrency=0) + + @pytest.mark.asyncio + async def test_concurrency_slot_limits_simultaneous_streams(self): + """At most max_concurrency streams can hold a slot simultaneously.""" + max_concurrency = 2 + limiter = ProviderRateLimiter( + rate_limit=100, rate_window=60, max_concurrency=max_concurrency + ) + + peak_concurrent = 0 + current_concurrent = 0 + lock = asyncio.Lock() + + async def stream_task(hold_time: float) -> None: + nonlocal peak_concurrent, current_concurrent + async with limiter.concurrency_slot(): + async with lock: + current_concurrent += 1 + if current_concurrent > peak_concurrent: + peak_concurrent = current_concurrent + await asyncio.sleep(hold_time) + async with lock: + current_concurrent -= 1 + + # Launch 5 tasks that each hold the slot; only 2 can be active at once + await asyncio.gather(*(stream_task(0.05) for _ in range(5))) + + assert peak_concurrent <= max_concurrency, ( + f"Concurrency exceeded: peak={peak_concurrent}, max={max_concurrency}" + ) + + @pytest.mark.asyncio + async def test_concurrency_slot_releases_on_exception(self): + """Slot is released even when the body raises an exception.""" + limiter = ProviderRateLimiter(rate_limit=100, rate_window=60, max_concurrency=1) + assert limiter._concurrency_sem is not None + + with pytest.raises(RuntimeError): + async with limiter.concurrency_slot(): + raise RuntimeError("boom") + + # Semaphore value should be restored (1 available again) + assert limiter._concurrency_sem._value == 1 + + @pytest.mark.asyncio + async def test_constructor_sets_max_concurrency(self): + """Constructor applies max_concurrency to an independent limiter.""" + limiter = ProviderRateLimiter(rate_limit=10, rate_window=60, max_concurrency=3) + assert limiter._concurrency_sem is not None + assert limiter._concurrency_sem._value == 3 + + @pytest.mark.asyncio + async def test_provider_owned_instances_are_isolated(self): + """Independent provider limiters do not share reactive block state.""" + nim = ProviderRateLimiter(rate_limit=10, rate_window=60) + openrouter = ProviderRateLimiter(rate_limit=20, rate_window=30) + + assert nim is not openrouter + nim.extend_reactive_block(1.0) + + assert nim.is_blocked() is True + assert openrouter.is_blocked() is False diff --git a/tests/providers/test_provider_runtime.py b/tests/providers/test_provider_runtime.py new file mode 100644 index 0000000..e18186c --- /dev/null +++ b/tests/providers/test_provider_runtime.py @@ -0,0 +1,534 @@ +import asyncio +import subprocess +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.application.errors import UnknownProviderError +from free_claude_code.config.nim import NimSettings +from free_claude_code.config.provider_catalog import ( + COHERE_DEFAULT_BASE, + GITHUB_MODELS_DEFAULT_BASE, + HUGGINGFACE_DEFAULT_BASE, + MINIMAX_DEFAULT_BASE, + PROVIDER_CATALOG, + SUPPORTED_PROVIDER_IDS, + VERCEL_AI_GATEWAY_DEFAULT_BASE, + ZAI_DEFAULT_BASE, +) +from free_claude_code.providers.cloudflare import CloudflareProvider +from free_claude_code.providers.deepseek import DeepSeekProvider +from free_claude_code.providers.gemini import GeminiProvider +from free_claude_code.providers.github_models import GitHubModelsProvider +from free_claude_code.providers.lmstudio import LMStudioProvider +from free_claude_code.providers.mistral import MistralProvider +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from free_claude_code.providers.open_router import OpenRouterProvider +from free_claude_code.providers.openai_chat import ( + OPENAI_CHAT_PROFILES, + OpenAIChatProvider, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter +from free_claude_code.providers.runtime import ( + ProviderRuntime, + build_provider_config, + create_provider, +) + + +def _make_settings(**overrides): + mock = MagicMock() + mock.model = "nvidia_nim/meta/llama3" + mock.model_opus = None + mock.model_sonnet = None + mock.model_haiku = None + mock.nvidia_nim_api_key = "test_key" + mock.open_router_api_key = "test_openrouter_key" + mock.mistral_api_key = "test_mistral_key" + mock.codestral_api_key = "test_codestral_key" + mock.deepseek_api_key = "test_deepseek_key" + mock.wafer_api_key = "test_wafer_key" + mock.minimax_api_key = "test_minimax_key" + mock.opencode_api_key = "test_opencode_key" + mock.vercel_ai_gateway_api_key = "test_vercel_key" + mock.huggingface_api_key = "test_huggingface_key" + mock.cohere_api_key = "test_cohere_key" + mock.github_models_token = "test_github_models_token" + mock.zai_api_key = "test_zai_key" + mock.lm_studio_base_url = "http://localhost:1234/v1" + mock.llamacpp_base_url = "http://localhost:8080/v1" + mock.ollama_base_url = "http://localhost:11434" + mock.nvidia_nim_proxy = "" + mock.open_router_proxy = "" + mock.lmstudio_proxy = "" + mock.llamacpp_proxy = "" + mock.mistral_proxy = "" + mock.codestral_proxy = "" + mock.kimi_proxy = "" + mock.kimi_api_key = "test_kimi_key" + mock.wafer_proxy = "" + mock.minimax_proxy = "" + mock.opencode_proxy = "" + mock.opencode_go_proxy = "" + mock.vercel_ai_gateway_proxy = "" + mock.huggingface_proxy = "" + mock.cohere_proxy = "" + mock.github_models_proxy = "" + mock.zai_proxy = "" + mock.fireworks_proxy = "" + mock.fireworks_api_key = "test_fireworks_key" + mock.cloudflare_api_token = "test_cloudflare_token" + mock.cloudflare_account_id = "test_cloudflare_account" + mock.cloudflare_proxy = "" + mock.gemini_api_key = "" + mock.gemini_proxy = "" + mock.groq_api_key = "" + mock.groq_proxy = "" + mock.cerebras_api_key = "" + mock.cerebras_proxy = "" + mock.provider_rate_limit = 40 + mock.provider_rate_window = 60 + mock.provider_max_concurrency = 5 + mock.http_read_timeout = 300.0 + mock.http_write_timeout = 10.0 + mock.http_connect_timeout = 10.0 + mock.enable_model_thinking = True + mock.log_raw_sse_events = False + mock.log_api_error_tracebacks = False + mock.nim = NimSettings() + for key, value in overrides.items(): + setattr(mock, key, value) + return mock + + +def test_importing_runtime_does_not_eager_load_other_adapters() -> None: + """Runtime metadata must not import every provider adapter up front.""" + code = ( + "import sys\n" + "import free_claude_code.providers.runtime\n" + "assert 'free_claude_code.providers.open_router' not in sys.modules\n" + ) + proc = subprocess.run( + [sys.executable, "-c", code], + check=False, + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stderr or proc.stdout + + +def test_provider_catalog_covers_advertised_provider_ids(): + assert set(PROVIDER_CATALOG) == set(SUPPORTED_PROVIDER_IDS) + assert set(OPENAI_CHAT_PROFILES) < set(PROVIDER_CATALOG) + for descriptor in PROVIDER_CATALOG.values(): + assert descriptor.provider_id + + +def test_ollama_descriptor_uses_local_openai_endpoint_semantics(): + descriptor = PROVIDER_CATALOG["ollama"] + + assert descriptor.default_base_url == "http://localhost:11434" + assert descriptor.local is True + + +@pytest.mark.parametrize( + ("provider_id", "expected_api_key"), + [ + ("lmstudio", "lm-studio"), + ("llamacpp", "llamacpp"), + ("ollama", "ollama"), + ], +) +def test_local_provider_factory_resolves_catalog_static_credential( + provider_id: str, + expected_api_key: str, +) -> None: + descriptor = PROVIDER_CATALOG[provider_id] + settings = _make_settings() + + config = build_provider_config(descriptor, settings) + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = create_provider(provider_id, settings) + + assert config.api_key == expected_api_key + assert isinstance(provider, OpenAIChatProvider) + assert provider._api_key == expected_api_key + + +def test_zai_descriptor_uses_fixed_cloud_base_url(): + descriptor = PROVIDER_CATALOG["zai"] + + assert descriptor.default_base_url == ZAI_DEFAULT_BASE + assert descriptor.base_url_attr is None + + +def test_zai_provider_config_ignores_stale_base_url_setting(): + descriptor = PROVIDER_CATALOG["zai"] + + config = build_provider_config( + descriptor, + _make_settings(zai_base_url="https://custom.zai.invalid/v1"), + ) + + assert config.base_url == ZAI_DEFAULT_BASE + + +def test_minimax_descriptor_uses_expected_endpoint_and_credential(): + descriptor = PROVIDER_CATALOG["minimax"] + + assert descriptor.default_base_url == MINIMAX_DEFAULT_BASE + assert descriptor.credential_env == "MINIMAX_API_KEY" + + +def test_cloudflare_descriptor_uses_api_root_not_account_url(): + descriptor = PROVIDER_CATALOG["cloudflare"] + + assert descriptor.default_base_url == "https://api.cloudflare.com/client/v4" + assert descriptor.base_url_attr is None + + +def test_create_cloudflare_provider_uses_account_scoped_base_url(): + settings = _make_settings( + cloudflare_api_token="test_cloudflare_token", + cloudflare_account_id="test-account", + ) + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = create_provider("cloudflare", settings) + + assert isinstance(provider, CloudflareProvider) + assert provider._base_url == ( + "https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1" + ) + + +def test_opencode_go_provider_config_uses_correct_base_url_and_name(): + with patch("httpx.AsyncClient"): + provider = create_provider("opencode_go", _make_settings()) + + assert isinstance(provider, OpenAIChatProvider) + assert provider._base_url == "https://opencode.ai/zen/go/v1" + assert provider._provider_name == "OPENCODE_GO" + assert provider._api_key == "test_opencode_key" + + +def test_opencode_go_catalog_uses_opencode_api_key() -> None: + desc = PROVIDER_CATALOG["opencode_go"] + + assert desc.credential_env == "OPENCODE_API_KEY" + assert desc.credential_attr == "opencode_api_key" + + +def test_build_provider_config_opencode_go_uses_opencode_api_key() -> None: + descriptor = PROVIDER_CATALOG["opencode_go"] + settings = _make_settings(opencode_api_key="shared-opencode-token") + + config = build_provider_config(descriptor, settings) + + assert config.api_key == "shared-opencode-token" + + +def test_vercel_descriptor_uses_openai_chat_gateway() -> None: + descriptor = PROVIDER_CATALOG["vercel"] + + assert descriptor.default_base_url == VERCEL_AI_GATEWAY_DEFAULT_BASE + assert descriptor.credential_env == "AI_GATEWAY_API_KEY" + assert descriptor.proxy_attr == "vercel_ai_gateway_proxy" + + +def test_huggingface_descriptor_uses_openai_chat_router() -> None: + descriptor = PROVIDER_CATALOG["huggingface"] + + assert descriptor.default_base_url == HUGGINGFACE_DEFAULT_BASE + assert descriptor.credential_env == "HUGGINGFACE_API_KEY" + assert descriptor.proxy_attr == "huggingface_proxy" + + +def test_cohere_descriptor_uses_openai_chat_compatibility_api() -> None: + descriptor = PROVIDER_CATALOG["cohere"] + + assert descriptor.default_base_url == COHERE_DEFAULT_BASE + assert descriptor.credential_env == "COHERE_API_KEY" + assert descriptor.proxy_attr == "cohere_proxy" + + +def test_github_models_descriptor_uses_openai_chat_inference_api() -> None: + descriptor = PROVIDER_CATALOG["github_models"] + + assert descriptor.default_base_url == GITHUB_MODELS_DEFAULT_BASE + assert descriptor.credential_env == "GITHUB_MODELS_TOKEN" + assert descriptor.proxy_attr == "github_models_proxy" + + +def test_build_provider_config_vercel_uses_gateway_key_and_proxy() -> None: + descriptor = PROVIDER_CATALOG["vercel"] + settings = _make_settings( + vercel_ai_gateway_api_key="vercel-token", + vercel_ai_gateway_proxy="http://proxy.test:8080", + ) + + config = build_provider_config(descriptor, settings) + + assert config.api_key == "vercel-token" + assert config.proxy == "http://proxy.test:8080" + + +def test_build_provider_config_huggingface_uses_api_key_and_proxy() -> None: + descriptor = PROVIDER_CATALOG["huggingface"] + settings = _make_settings( + huggingface_api_key="hf-token", + huggingface_proxy="http://proxy.test:8080", + ) + + config = build_provider_config(descriptor, settings) + + assert config.api_key == "hf-token" + assert config.proxy == "http://proxy.test:8080" + + +def test_build_provider_config_cohere_uses_api_key_and_proxy() -> None: + descriptor = PROVIDER_CATALOG["cohere"] + settings = _make_settings( + cohere_api_key="cohere-token", + cohere_proxy="http://proxy.test:8080", + ) + + config = build_provider_config(descriptor, settings) + + assert config.api_key == "cohere-token" + assert config.proxy == "http://proxy.test:8080" + + +def test_build_provider_config_github_models_uses_token_and_proxy() -> None: + descriptor = PROVIDER_CATALOG["github_models"] + settings = _make_settings( + github_models_token="github-token", + github_models_proxy="http://proxy.test:8080", + ) + + config = build_provider_config(descriptor, settings) + + assert config.api_key == "github-token" + assert config.proxy == "http://proxy.test:8080" + + +def test_create_provider_uses_openai_chat_openrouter_by_default(): + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = create_provider("open_router", _make_settings()) + + assert isinstance(provider, OpenRouterProvider) + + +def test_create_provider_instantiates_each_builtin(): + settings = _make_settings( + gemini_api_key="test_gemini_key", + groq_api_key="test_groq_key", + cerebras_api_key="test_cerebras_key", + fireworks_api_key="test_fireworks_key", + cloudflare_api_token="test_cloudflare_token", + cloudflare_account_id="test_cloudflare_account", + vercel_ai_gateway_api_key="test_vercel_key", + huggingface_api_key="test_huggingface_key", + cohere_api_key="test_cohere_key", + github_models_token="test_github_models_token", + kimi_api_key="test_kimi_key", + provider_rate_limit=7, + provider_rate_window=11, + provider_max_concurrency=3, + sambanova_api_key="test_sambanova_key", + ) + cases = { + "nvidia_nim": NvidiaNimProvider, + "open_router": OpenRouterProvider, + "mistral": MistralProvider, + "mistral_codestral": OpenAIChatProvider, + "deepseek": DeepSeekProvider, + "kimi": OpenAIChatProvider, + "minimax": OpenAIChatProvider, + "fireworks": OpenAIChatProvider, + "cloudflare": CloudflareProvider, + "lmstudio": LMStudioProvider, + "llamacpp": OpenAIChatProvider, + "ollama": OpenAIChatProvider, + "wafer": OpenAIChatProvider, + "opencode": OpenAIChatProvider, + "opencode_go": OpenAIChatProvider, + "vercel": OpenAIChatProvider, + "huggingface": OpenAIChatProvider, + "cohere": OpenAIChatProvider, + "github_models": GitHubModelsProvider, + "zai": OpenAIChatProvider, + "gemini": GeminiProvider, + "groq": OpenAIChatProvider, + "sambanova": OpenAIChatProvider, + "cerebras": OpenAIChatProvider, + } + sentinel_limiter = MagicMock(spec=ProviderRateLimiter) + + with ( + patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"), + patch("httpx.AsyncClient"), + patch( + "free_claude_code.providers.runtime.factory.ProviderRateLimiter", + return_value=sentinel_limiter, + ) as limiter_factory, + ): + for provider_id, provider_cls in cases.items(): + provider = create_provider(provider_id, settings) + + assert isinstance(provider, provider_cls) + assert provider._rate_limiter is sentinel_limiter + limiter_factory.assert_called_once_with( + rate_limit=7, + rate_window=11, + max_concurrency=3, + ) + limiter_factory.reset_mock() + + assert set(cases) == set(PROVIDER_CATALOG) + + +def test_provider_runtime_caches_by_provider_id(): + runtime = ProviderRuntime(_make_settings()) + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + first = runtime.resolve_provider("nvidia_nim") + second = runtime.resolve_provider("nvidia_nim") + + assert first is second + + +def test_provider_runtime_provider_owns_one_limiter() -> None: + runtime = ProviderRuntime(_make_settings()) + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + first = runtime.resolve_provider("nvidia_nim") + second = runtime.resolve_provider("nvidia_nim") + + assert isinstance(first, NvidiaNimProvider) + assert isinstance(second, NvidiaNimProvider) + assert first._rate_limiter is second._rate_limiter + + +def test_separate_provider_runtimes_never_share_limiters() -> None: + first_runtime = ProviderRuntime(_make_settings()) + second_runtime = ProviderRuntime(_make_settings()) + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + first = first_runtime.resolve_provider("nvidia_nim") + second = second_runtime.resolve_provider("nvidia_nim") + + assert isinstance(first, NvidiaNimProvider) + assert isinstance(second, NvidiaNimProvider) + assert first is not second + assert first._rate_limiter is not second._rate_limiter + + +def test_different_providers_in_one_runtime_have_independent_limiters() -> None: + runtime = ProviderRuntime(_make_settings()) + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + nim = runtime.resolve_provider("nvidia_nim") + open_router = runtime.resolve_provider("open_router") + + assert isinstance(nim, NvidiaNimProvider) + assert isinstance(open_router, OpenRouterProvider) + assert nim._rate_limiter is not open_router._rate_limiter + + +def test_unknown_provider_raises_unknown_provider_type_error(): + with pytest.raises(UnknownProviderError, match="Unknown provider_type"): + create_provider("unknown", _make_settings()) + + +@pytest.mark.asyncio +async def test_provider_runtime_cleanup_runs_all_even_if_one_fails() -> None: + """Successful providers leave the cache while failed providers remain retryable.""" + p1 = MagicMock() + p1.cleanup = AsyncMock(side_effect=RuntimeError("first")) + p2 = MagicMock() + p2.cleanup = AsyncMock() + runtime = ProviderRuntime(_make_settings(), {"a": p1, "b": p2}) + + with pytest.raises(RuntimeError, match="first"): + await runtime.cleanup() + + p1.cleanup.assert_awaited_once() + p2.cleanup.assert_awaited_once() + assert runtime.is_cached("a") + assert not runtime.is_cached("b") + + p1.cleanup = AsyncMock() + await runtime.cleanup() + + p1.cleanup.assert_awaited_once() + assert not runtime.is_cached("a") + + +@pytest.mark.asyncio +async def test_cancelled_cleanup_retains_current_and_unvisited_providers() -> None: + first = MagicMock() + second = MagicMock() + third = MagicMock() + second_started = asyncio.Event() + second_attempts = 0 + + async def cleanup_second() -> None: + nonlocal second_attempts + second_attempts += 1 + if second_attempts == 1: + second_started.set() + await asyncio.Event().wait() + + first.cleanup = AsyncMock() + second.cleanup = AsyncMock(side_effect=cleanup_second) + third.cleanup = AsyncMock() + runtime = ProviderRuntime( + _make_settings(), + {"first": first, "second": second, "third": third}, + ) + cleanup_task = asyncio.create_task(runtime.cleanup()) + await second_started.wait() + + cleanup_task.cancel() + with pytest.raises(asyncio.CancelledError): + await cleanup_task + + assert runtime.is_cached("first") is False + assert runtime.is_cached("second") is True + assert runtime.is_cached("third") is True + first.cleanup.assert_awaited_once_with() + third.cleanup.assert_not_awaited() + + await runtime.cleanup() + + first.cleanup.assert_awaited_once_with() + assert second.cleanup.await_count == 2 + third.cleanup.assert_awaited_once_with() + assert runtime.is_cached("first") is False + assert runtime.is_cached("second") is False + assert runtime.is_cached("third") is False + + +@pytest.mark.asyncio +async def test_provider_runtime_cleanup_exceptiongroup_on_multiple_failures() -> None: + p1 = MagicMock() + p1.cleanup = AsyncMock(side_effect=RuntimeError("a")) + p2 = MagicMock() + p2.cleanup = AsyncMock(side_effect=RuntimeError("b")) + runtime = ProviderRuntime(_make_settings(), {"x": p1, "y": p2}) + + with pytest.raises(ExceptionGroup) as exc_info: + await runtime.cleanup() + + assert len(exc_info.value.exceptions) == 2 + assert runtime.is_cached("x") + assert runtime.is_cached("y") + + p1.cleanup = AsyncMock() + p2.cleanup = AsyncMock() + await runtime.cleanup() + + assert not runtime.is_cached("x") + assert not runtime.is_cached("y") diff --git a/tests/providers/test_provider_transport_logging.py b/tests/providers/test_provider_transport_logging.py new file mode 100644 index 0000000..d8cf830 --- /dev/null +++ b/tests/providers/test_provider_transport_logging.py @@ -0,0 +1,104 @@ +"""Tests for credential-safe provider transport logging.""" + +import logging +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, patch + +import httpx +import openai +import pytest + +from free_claude_code.config.nim import NimSettings +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +def _provider(*, verbose: bool = False) -> NvidiaNimProvider: + return NvidiaNimProvider( + ProviderConfig( + api_key="k", + base_url="http://localhost:1/v1", + log_api_error_tracebacks=verbose, + ), + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + + +@asynccontextmanager +async def _noop_slot(): + yield + + +@pytest.mark.asyncio +async def test_stream_failure_default_logs_exclude_exception_text(caplog) -> None: + provider = _provider() + with ( + patch.object( + provider, + "_create_stream", + new_callable=AsyncMock, + side_effect=RuntimeError("SECRET_OPENAI_COMPAT"), + ), + patch.object(provider._rate_limiter, "concurrency_slot", _noop_slot), + caplog.at_level(logging.ERROR), + pytest.raises(ExecutionFailure), + ): + [event async for event in provider.stream_response(make_messages_request())] + + messages = " | ".join(record.getMessage() for record in caplog.records) + assert "SECRET_OPENAI_COMPAT" not in messages + assert "exc_type=RuntimeError" in messages + + +@pytest.mark.asyncio +async def test_stream_failure_default_logs_cause_types_only(caplog) -> None: + provider = _provider() + error = openai.APIConnectionError( + request=httpx.Request("POST", "http://localhost:1/v1/chat/completions") + ) + error.__cause__ = httpx.ConnectError("SECRET_CAUSE_DETAIL") + with ( + patch.object( + provider, + "_create_stream", + new_callable=AsyncMock, + side_effect=error, + ), + patch.object(provider._rate_limiter, "concurrency_slot", _noop_slot), + caplog.at_level(logging.ERROR), + pytest.raises(ExecutionFailure), + ): + [event async for event in provider.stream_response(make_messages_request())] + + messages = " | ".join(record.getMessage() for record in caplog.records) + assert "SECRET_CAUSE_DETAIL" not in messages + assert "exc_type=APIConnectionError" in messages + assert "cause_types=ConnectError" in messages + + +@pytest.mark.asyncio +async def test_stream_failure_verbose_traceback_redacts_credentials(caplog) -> None: + provider = _provider(verbose=True) + with ( + patch.object( + provider, + "_create_stream", + new_callable=AsyncMock, + side_effect=RuntimeError( + "api_key=SECRET_OPENAI_COMPAT useful traceback detail" + ), + ), + patch.object(provider._rate_limiter, "concurrency_slot", _noop_slot), + caplog.at_level(logging.ERROR), + pytest.raises(ExecutionFailure), + ): + [event async for event in provider.stream_response(make_messages_request())] + + messages = " | ".join(record.getMessage() for record in caplog.records) + assert "api_key=" in messages + assert "useful traceback detail" in messages + assert "SECRET_OPENAI_COMPAT" not in messages diff --git a/tests/providers/test_sambanova.py b/tests/providers/test_sambanova.py new file mode 100644 index 0000000..be20d0c --- /dev/null +++ b/tests/providers/test_sambanova.py @@ -0,0 +1,195 @@ +"""Tests for SambaNova Cloud (OpenAI-compatible) provider.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import SAMBANOVA_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def make_request(**overrides): + return make_messages_request("Meta-Llama-3.3-70B-Instruct", **overrides) + + +@pytest.fixture +def sambanova_config(): + return ProviderConfig( + api_key="test_sambanova_key", + base_url=SAMBANOVA_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def sambanova_provider(sambanova_config): + return profiled_provider( + "sambanova", sambanova_config, rate_limiter=passthrough_rate_limiter() + ) + + +def test_default_base_url_constant(): + assert SAMBANOVA_DEFAULT_BASE == "https://api.sambanova.ai/v1" + + +def test_init_uses_default_base_url_and_api_key(sambanova_config): + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = profiled_provider( + "sambanova", sambanova_config, rate_limiter=passthrough_rate_limiter() + ) + + assert provider._api_key == "test_sambanova_key" + assert provider._base_url == SAMBANOVA_DEFAULT_BASE + mock_openai.assert_called_once() + + +def test_init_strips_trailing_slash(sambanova_config): + config = replace(sambanova_config, base_url=f"{SAMBANOVA_DEFAULT_BASE}/") + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = profiled_provider( + "sambanova", config, rate_limiter=passthrough_rate_limiter() + ) + + assert provider._base_url == SAMBANOVA_DEFAULT_BASE + + +def test_build_request_body_basic(sambanova_provider): + """Basic request body conversion attaches system message and keeps max_tokens.""" + body = sambanova_provider._build_request_body(make_request()) + + assert body["model"] == "Meta-Llama-3.3-70B-Instruct" + assert body["messages"][0]["role"] == "system" + assert body["max_tokens"] == 100 + assert "max_completion_tokens" not in body + + +def test_build_request_body_preserves_caller_extra_body(sambanova_provider): + req = make_request(extra_body={"metadata": {"user": "u1"}}) + + body = sambanova_provider._build_request_body(req) + + eb = body.get("extra_body") + assert isinstance(eb, dict) + assert eb.get("metadata") == {"user": "u1"} + + +@pytest.mark.asyncio +async def test_stream_response_text(sambanova_provider): + """Text content deltas are emitted as text blocks.""" + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello from SambaNova", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + sambanova_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event async for event in sambanova_provider.stream_response(make_request()) + ] + + assert any( + '"text_delta"' in event and "Hello from SambaNova" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_tool_call(sambanova_provider): + mock_tc = MagicMock() + mock_tc.index = 0 + mock_tc.id = "call_1" + mock_tc.function = MagicMock() + mock_tc.function.name = "Read" + mock_tc.function.arguments = '{"file_path":"a.py"}' + + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock(content=None, reasoning_content=None, tool_calls=[mock_tc]), + finish_reason="tool_calls", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + sambanova_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event async for event in sambanova_provider.stream_response(make_request()) + ] + + assert any( + '"content_block_start"' in event and '"tool_use"' in event for event in events + ) + assert any( + '"input_json_delta"' in event and "file_path" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(sambanova_provider): + """reasoning_content deltas are emitted as thinking blocks.""" + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking via SambaNova", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + sambanova_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event async for event in sambanova_provider.stream_response(make_request()) + ] + + assert any( + '"thinking_delta"' in event and "Thinking via SambaNova" in event + for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(sambanova_provider): + sambanova_provider._client = AsyncMock() + + await sambanova_provider.cleanup() + + sambanova_provider._client.close.assert_called_once() diff --git a/tests/providers/test_stream_recovery.py b/tests/providers/test_stream_recovery.py new file mode 100644 index 0000000..f0ad617 --- /dev/null +++ b/tests/providers/test_stream_recovery.py @@ -0,0 +1,259 @@ +"""Provider-owned stream retry and holdback policy.""" + +import httpx +import openai + +from free_claude_code.providers.stream_recovery import ( + EARLY_TRANSPARENT_MAX_RETRIES, + EARLY_TRANSPARENT_TOTAL_ATTEMPTS, + MIDSTREAM_RECOVERY_ATTEMPTS, + RecoveryController, + RecoveryFailureAction, + RecoveryHoldbackBuffer, + is_retryable_stream_error, +) + + +def _statusless_openai_api_error( + message: str, body: object | None = None +) -> openai.APIError: + return openai.APIError( + message, + request=httpx.Request("POST", "https://provider.test/messages"), + body=body, + ) + + +def test_early_transparent_retry_total_attempts_is_five() -> None: + assert EARLY_TRANSPARENT_TOTAL_ATTEMPTS == 5 + assert EARLY_TRANSPARENT_MAX_RETRIES == 4 + + +def test_midstream_recovery_attempts_total_is_five() -> None: + assert MIDSTREAM_RECOVERY_ATTEMPTS == 5 + + +def test_retryable_stream_error_classifies_transport_and_http_status() -> None: + assert is_retryable_stream_error(httpx.ReadError("cut off")) + + request = httpx.Request("GET", "https://example.test") + assert is_retryable_stream_error( + httpx.HTTPStatusError( + "server error", request=request, response=httpx.Response(503) + ) + ) + assert not is_retryable_stream_error( + httpx.HTTPStatusError( + "bad request", request=request, response=httpx.Response(400) + ) + ) + + +def test_stream_retry_preserves_timeout_scope() -> None: + request = httpx.Request("POST", "https://provider.test/messages") + + assert is_retryable_stream_error(httpx.ReadTimeout("read", request=request)) + assert not is_retryable_stream_error( + httpx.ConnectTimeout("connect", request=request) + ) + assert not is_retryable_stream_error(httpx.WriteTimeout("write", request=request)) + assert not is_retryable_stream_error(httpx.PoolTimeout("pool", request=request)) + + +def test_retryable_stream_error_classifies_statusless_api_error_body_status() -> None: + assert is_retryable_stream_error( + _statusless_openai_api_error( + "stream embedded error", + {"error": {"message": "internal failure", "code": 500}}, + ) + ) + + +def test_retryable_stream_error_classifies_statusless_internal_error_type() -> None: + assert is_retryable_stream_error( + _statusless_openai_api_error( + "stream embedded error", + {"error": {"message": "internal failure", "type": "internal_server_error"}}, + ) + ) + + +def test_retryable_stream_error_classifies_resource_exhausted_text() -> None: + assert is_retryable_stream_error( + _statusless_openai_api_error( + "ResourceExhausted: limit reached while generating response", + {"error": {"message": "ResourceExhausted: limit reached"}}, + ) + ) + + +def test_retryable_stream_error_does_not_retry_bad_request_status() -> None: + request = httpx.Request("POST", "https://provider.test/messages") + assert not is_retryable_stream_error( + openai.BadRequestError( + "bad request", + response=httpx.Response(400, request=request), + body={"error": {"message": "bad request"}}, + ) + ) + + +def test_recovery_controller_advances_early_retry_and_discards_holdback() -> None: + controller = RecoveryController(provider_name="TEST", request_id="REQ") + + assert controller.push("hidden") == [] + decision = controller.advance_failure( + httpx.ReadError("early cutoff"), + stream_opened=True, + generated_output=True, + complete_tool_salvageable=False, + ) + + assert decision.action == RecoveryFailureAction.EARLY_RETRY + assert decision.early_retry_attempt == 1 + assert controller.early_retries == 1 + assert not controller.committed + assert not controller.has_buffered + assert controller.flush() == [] + + +def test_recovery_controller_retries_statusless_transient_api_error() -> None: + controller = RecoveryController(provider_name="TEST", request_id="REQ") + + decision = controller.advance_failure( + _statusless_openai_api_error( + "ResourceExhausted: limit reached while generating response", + {"error": {"message": "ResourceExhausted: limit reached"}}, + ), + stream_opened=True, + generated_output=False, + complete_tool_salvageable=False, + ) + + assert decision.action == RecoveryFailureAction.EARLY_RETRY + assert decision.retryable + assert decision.early_retry_attempt == 1 + + +def test_recovery_controller_respects_early_retry_limit() -> None: + controller = RecoveryController(provider_name="TEST", request_id=None) + + for attempt in range(1, EARLY_TRANSPARENT_MAX_RETRIES + 1): + decision = controller.advance_failure( + httpx.ReadError("cutoff"), + stream_opened=True, + generated_output=False, + complete_tool_salvageable=False, + ) + assert decision.action == RecoveryFailureAction.EARLY_RETRY + assert decision.early_retry_attempt == attempt + + decision = controller.advance_failure( + httpx.ReadError("cutoff"), + stream_opened=True, + generated_output=False, + complete_tool_salvageable=False, + ) + + assert decision.action == RecoveryFailureAction.FINAL_ERROR + assert controller.early_retries == EARLY_TRANSPARENT_MAX_RETRIES + + +def test_recovery_controller_classifies_midstream_recovery_after_commit() -> None: + controller = RecoveryController(provider_name="TEST", request_id=None) + + assert controller.push("event: content_block_delta\n\n") == [] + assert controller.flush() == ["event: content_block_delta\n\n"] + decision = controller.advance_failure( + httpx.ReadError("midstream cutoff"), + stream_opened=True, + generated_output=True, + complete_tool_salvageable=False, + ) + + assert decision.action == RecoveryFailureAction.MIDSTREAM_RECOVERY + assert decision.retryable + assert decision.committed + assert controller.flush_uncommitted(decision) == [] + + +def test_recovery_controller_flushes_uncommitted_midstream_decision() -> None: + controller = RecoveryController(provider_name="TEST", request_id=None) + + assert controller.push("event: content_block_delta\n\n") == [] + decision = controller.advance_failure( + httpx.ReadError("midstream cutoff"), + stream_opened=True, + generated_output=True, + complete_tool_salvageable=True, + ) + + assert decision.action == RecoveryFailureAction.MIDSTREAM_RECOVERY + assert not decision.committed + assert decision.has_buffered + assert not controller.committed + assert controller.has_buffered + + assert controller.flush_uncommitted(decision) == ["event: content_block_delta\n\n"] + assert not decision.committed + assert decision.has_buffered + assert controller.committed + assert not controller.has_buffered + + +def test_recovery_controller_non_retryable_error_is_final() -> None: + request = httpx.Request("POST", "https://example.test/messages") + error = httpx.HTTPStatusError( + "bad request", + request=request, + response=httpx.Response(400, request=request), + ) + controller = RecoveryController(provider_name="TEST", request_id=None) + + decision = controller.advance_failure( + error, + stream_opened=True, + generated_output=True, + complete_tool_salvageable=False, + ) + + assert decision.action == RecoveryFailureAction.FINAL_ERROR + assert not decision.retryable + assert controller.early_retries == 0 + + +def test_holdback_buffers_until_delay_then_commits() -> None: + now = [10.0] + holdback = RecoveryHoldbackBuffer(holdback_seconds=0.75, now=lambda: now[0]) + + assert holdback.push("event: content_block_start\n\n") == [] + now[0] += 0.74 + assert holdback.push("event: content_block_delta\n\n") == [] + assert not holdback.committed + + now[0] += 0.01 + flushed = holdback.push("event: content_block_stop\n\n") + assert flushed == [ + "event: content_block_start\n\n", + "event: content_block_delta\n\n", + "event: content_block_stop\n\n", + ] + assert holdback.committed + assert holdback.push("event: message_stop\n\n") == ["event: message_stop\n\n"] + + +def test_holdback_flushes_at_internal_buffer_cap() -> None: + holdback = RecoveryHoldbackBuffer(max_bytes=5, now=lambda: 1.0) + + assert holdback.push("ab") == [] + assert holdback.push("cde") == ["ab", "cde"] + assert holdback.committed + + +def test_holdback_discard_drops_uncommitted_events() -> None: + holdback = RecoveryHoldbackBuffer(now=lambda: 1.0) + + assert holdback.push("hidden") == [] + holdback.discard() + + assert holdback.flush() == [] diff --git a/tests/providers/test_streaming_errors.py b/tests/providers/test_streaming_errors.py new file mode 100644 index 0000000..d0ae107 --- /dev/null +++ b/tests/providers/test_streaming_errors.py @@ -0,0 +1,1486 @@ +"""Tests for streaming error handling in providers/nvidia_nim/client.py.""" + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import openai +import pytest + +from free_claude_code.config.nim import NimSettings +from free_claude_code.core.anthropic.stream_contracts import ( + parse_sse_text, +) +from free_claude_code.core.anthropic.streaming import ( + AnthropicStreamLedger, + make_text_recovery_body, +) +from free_claude_code.core.failures import ExecutionFailure +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from free_claude_code.providers.openai_chat.provider import ( + _OpenAIChatStreamRunner, +) +from free_claude_code.providers.openai_chat.tool_calls import ( + OpenAIToolCallAssembler, + has_committed_sse_output, + iter_heuristic_tool_use_sse, +) +from free_claude_code.providers.stream_recovery import ( + MIDSTREAM_RECOVERY_ATTEMPTS, + TruncatedProviderStreamError, +) +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter + + +class AsyncStreamMock: + """Async iterable mock that yields chunks then optionally raises.""" + + def __init__(self, chunks, error=None): + self._chunks = chunks + self._error = error + + def __aiter__(self): + return self._aiter() + + async def _aiter(self): + for chunk in self._chunks: + yield chunk + if self._error: + raise self._error + + +class ClosableAsyncStreamMock(AsyncStreamMock): + """Async stream mock that records cleanup.""" + + def __init__(self, chunks, error=None): + super().__init__(chunks, error=error) + self.closed = False + + async def aclose(self): + self.closed = True + + +def _make_provider(): + """Create a provider instance for testing.""" + config = ProviderConfig( + api_key="test_key", + base_url="https://test.api.nvidia.com/v1", + rate_limit=10, + rate_window=60, + ) + return NvidiaNimProvider( + config, + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + + +def _make_tool_assembler(provider: NvidiaNimProvider) -> OpenAIToolCallAssembler: + return OpenAIToolCallAssembler( + record_extra_content=provider._record_tool_call_extra_content + ) + + +def _make_provider_with_thinking_enabled(enabled: bool): + """Create a provider instance with thinking explicitly enabled or disabled.""" + config = ProviderConfig( + api_key="test_key", + base_url="https://test.api.nvidia.com/v1", + rate_limit=10, + rate_window=60, + enable_thinking=enabled, + ) + return NvidiaNimProvider( + config, + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + + +def _make_request(model: str = "test-model", stream: bool = True, **overrides: object): + """Create a concrete request matching the original streaming-test defaults.""" + request_overrides: dict[str, object] = { + "messages": [], + "max_tokens": 4096, + "temperature": None, + "top_p": None, + "system": None, + "tools": None, + "extra_body": None, + "thinking": None, + "stream": stream, + } + request_overrides.update(overrides) + return make_messages_request(model, **request_overrides) + + +def _make_stream_runner( + provider: NvidiaNimProvider, + *, + request=None, + request_id: str | None = None, +) -> _OpenAIChatStreamRunner: + return _OpenAIChatStreamRunner( + provider, + request=request or _make_request(), + input_tokens=0, + request_id=request_id, + thinking_enabled=None, + ) + + +def _make_chunk( + content=None, finish_reason=None, tool_calls=None, reasoning_content=None +): + """Create a mock streaming chunk.""" + delta = MagicMock() + delta.content = content + delta.tool_calls = tool_calls + delta.reasoning_content = reasoning_content + + choice = MagicMock() + choice.delta = delta + choice.finish_reason = finish_reason + + chunk = MagicMock() + chunk.choices = [choice] + chunk.usage = None + return chunk + + +def _make_tool_calls_chunk(*, name: str, arguments: str, tool_id: str, index: int = 0): + """Single OpenAI-style tool_calls delta (starts a native streamed tool block).""" + tc = MagicMock() + tc.index = index + tc.id = tool_id + fn = MagicMock() + fn.name = name + fn.arguments = arguments + tc.function = fn + return _make_chunk(tool_calls=[tc]) + + +async def _collect_stream(provider, request): + """Collect all SSE events from a stream.""" + return [e async for e in provider.stream_response(request)] + + +async def _collect_stream_error(provider, request, **kwargs) -> ExecutionFailure: + with pytest.raises(ExecutionFailure) as exc_info: + [e async for e in provider.stream_response(request, **kwargs)] + return exc_info.value + + +async def _collect_stream_and_error( + provider, request, **kwargs +) -> tuple[list[str], ExecutionFailure]: + events: list[str] = [] + with pytest.raises(ExecutionFailure) as exc_info: + async for event in provider.stream_response(request, **kwargs): + events.extend((event,)) + return events, exc_info.value + + +def _assert_no_content_deltas_after_error_text( + events: list[str], error_substr: str +) -> None: + """After the error text delta, only block close + message tail events may follow.""" + parsed = parse_sse_text("".join(events)) + first_error_idx = None + for i, ev in enumerate(parsed): + if ev.event != "content_block_delta": + continue + delta = ev.data.get("delta", {}) + if delta.get("type") == "text_delta" and error_substr in str( + delta.get("text", "") + ): + first_error_idx = i + break + assert first_error_idx is not None, (error_substr, "".join(events)) + for ev in parsed[first_error_idx + 1 :]: + assert ev.event in ("content_block_stop", "message_delta", "message_stop"), ( + ev.event, + ev.data, + ) + + +def _assert_error_not_in_text_deltas_after_tool( + events: list[str], error_substr: str +) -> None: + """Transport errors after a native tool call must not use assistant text_delta (issue #206).""" + blob = "".join(events) + for ev in parse_sse_text(blob): + if ev.event != "content_block_delta": + continue + delta = ev.data.get("delta", {}) + if delta.get("type") == "text_delta" and error_substr in str( + delta.get("text", "") + ): + raise AssertionError( + f"error leaked as text_delta after tool_use: {ev.data!r} full={blob!r}" + ) + + +class TestStreamingExceptionHandling: + """Tests for error paths during stream_response.""" + + @pytest.mark.asyncio + async def test_pre_start_api_error_raises_provider_error(self): + """Before holdback commit, provider failures raise for API-level non-200.""" + provider = _make_provider() + request = _make_request() + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=RuntimeError("API failed"), + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + error = await _collect_stream_error(provider, request) + + assert "API failed" in error.message + + @pytest.mark.asyncio + async def test_read_timeout_with_empty_message_raises_fallback(self): + """ReadTimeout(TimeoutError()) should raise a non-empty timeout message.""" + provider = _make_provider() + request = _make_request() + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=httpx.ReadTimeout(""), + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + patch("asyncio.sleep", new_callable=AsyncMock), + ): + error = await _collect_stream_error( + provider, + request, + request_id="req_timeout123", + ) + + assert "timed out after" in error.message + assert "Request ID: req_timeout123" in error.message + + @pytest.mark.asyncio + async def test_error_after_precommit_partial_content_raises(self): + """Precommit partial text is discarded so the API can return non-200.""" + provider = _make_provider() + request = _make_request() + + chunk1 = _make_chunk(content="Hello ") + stream_mock = AsyncStreamMock([chunk1], error=RuntimeError("Connection lost")) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + error = await _collect_stream_error(provider, request) + + assert "Connection lost" in error.message + + @pytest.mark.asyncio + async def test_error_after_native_tool_call_closes_block_then_raises(self): + """A provider closes tool state, then leaves terminal serialization to API.""" + provider = _make_provider() + request = _make_request() + tool_chunk = _make_tool_calls_chunk( + name="echo_smoke", arguments="{}", tool_id="call_206", index=0 + ) + stream_mock = AsyncStreamMock( + [tool_chunk], error=RuntimeError("Connection lost after tool") + ) + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + events, error = await _collect_stream_and_error(provider, request) + event_text = "".join(events) + parsed = parse_sse_text(event_text) + assert "tool_use" in event_text + assert parsed[-1].event == "content_block_stop" + assert "Connection lost after tool" in error.message + assert "Connection lost after tool" not in event_text + assert "event: error\n" not in event_text + assert "message_stop" not in event_text + _assert_error_not_in_text_deltas_after_tool( + events, "Connection lost after tool" + ) + + @pytest.mark.asyncio + async def test_empty_response_gets_space(self): + """Empty response with no text/tools gets a single space text block.""" + provider = _make_provider() + request = _make_request() + + empty_chunk = _make_chunk(finish_reason="stop") + stream_mock = AsyncStreamMock([empty_chunk]) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + events = await _collect_stream(provider, request) + + event_text = "".join(events) + assert '"text_delta"' in event_text + assert "message_stop" in event_text + + @pytest.mark.asyncio + async def test_upstream_completion_tokens_null_emits_int_usage(self): + """NIM/GLM may send usage.completion_tokens=null; final SSE must not use JSON null.""" + provider = _make_provider() + request = _make_request() + + delta = SimpleNamespace( + content="hello", + tool_calls=None, + reasoning_content=None, + ) + choice = SimpleNamespace(delta=delta, finish_reason="stop") + usage = SimpleNamespace(completion_tokens=None, prompt_tokens=None) + chunk = SimpleNamespace(choices=[choice], usage=usage) + stream_mock = AsyncStreamMock([chunk]) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + events = await _collect_stream(provider, request) + + parsed = parse_sse_text("".join(events)) + delta_events = [e for e in parsed if e.event == "message_delta"] + assert len(delta_events) == 1 + usage_out = delta_events[0].data.get("usage", {}) + assert isinstance(usage_out.get("output_tokens"), int) + assert usage_out["output_tokens"] is not None + assert '"output_tokens": null' not in "".join(events) + + @pytest.mark.asyncio + async def test_reasoning_only_stream_emits_placeholder_text(self): + """When the model streams only ``reasoning_content`` (no ``content``), add text block. + + NIM / some templates may emit no main ``content``; a minimal text block matches + the empty-body placeholder and helps clients that expect a text segment. + """ + provider = _make_provider_with_thinking_enabled(True) + request = _make_request() + chunk1 = _make_chunk(reasoning_content="reasoning only from provider") + chunk2 = _make_chunk(finish_reason="stop") + stream_mock = AsyncStreamMock([chunk1, chunk2]) + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + events = await _collect_stream(provider, request) + event_text = "".join(events) + assert "thinking_delta" in event_text + assert '"text_delta"' in event_text + assert "message_stop" in event_text + + @pytest.mark.asyncio + async def test_stream_with_thinking_content(self): + """Thinking content via think tags is emitted as thinking blocks.""" + provider = _make_provider() + request = _make_request() + + chunk1 = _make_chunk(content="reasoninganswer") + chunk2 = _make_chunk(finish_reason="stop") + stream_mock = AsyncStreamMock([chunk1, chunk2]) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + events = await _collect_stream(provider, request) + + event_text = "".join(events) + assert "thinking" in event_text + assert "reasoning" in event_text + assert "answer" in event_text + + @pytest.mark.asyncio + async def test_stream_with_reasoning_content_field(self): + """reasoning_content delta field is emitted as thinking block.""" + provider = _make_provider() + request = _make_request() + + chunk1 = _make_chunk(reasoning_content="I think...") + chunk2 = _make_chunk(content="The answer") + chunk3 = _make_chunk(finish_reason="stop") + stream_mock = AsyncStreamMock([chunk1, chunk2, chunk3]) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + events = await _collect_stream(provider, request) + + event_text = "".join(events) + assert "thinking_delta" in event_text + assert "I think..." in event_text + assert "The answer" in event_text + + @pytest.mark.asyncio + async def test_stream_with_empty_reasoning_content_starts_thinking_block_only(self): + """Empty reasoning_content is stateful but must not emit visible thinking text.""" + provider = _make_provider() + request = _make_request() + + chunk1 = _make_chunk(reasoning_content="") + chunk2 = _make_chunk(finish_reason="stop") + stream_mock = AsyncStreamMock([chunk1, chunk2]) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + events = await _collect_stream(provider, request) + + parsed = parse_sse_text("".join(events)) + thinking_starts = [ + event + for event in parsed + if event.event == "content_block_start" + and event.data["content_block"]["type"] == "thinking" + ] + thinking_deltas = [ + event + for event in parsed + if event.event == "content_block_delta" + and event.data["delta"]["type"] == "thinking_delta" + ] + assert len(thinking_starts) == 1 + assert thinking_deltas == [] + assert parsed[-1].event == "message_stop" + + @pytest.mark.asyncio + async def test_stream_with_reasoning_content_suppressed_when_disabled(self): + """reasoning deltas are stripped while normal text still streams.""" + provider = _make_provider_with_thinking_enabled(False) + request = _make_request() + + chunk1 = _make_chunk(reasoning_content="I think...") + chunk2 = _make_chunk(content="secretThe answer") + chunk3 = _make_chunk(finish_reason="stop") + stream_mock = AsyncStreamMock([chunk1, chunk2, chunk3]) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + events = await _collect_stream(provider, request) + + event_text = "".join(events) + assert "thinking_delta" not in event_text + assert "I think..." not in event_text + assert "secret" not in event_text + assert "The answer" in event_text + + @pytest.mark.asyncio + async def test_stream_with_upstream_405_mentions_provider_name(self): + """HTTP 405s are surfaced as upstream method/endpoint rejections.""" + provider = _make_provider() + request = _make_request() + + response = httpx.Response( + status_code=405, + request=httpx.Request("POST", "https://example.com/v1/chat/completions"), + ) + error = httpx.HTTPStatusError( + "Method Not Allowed", + request=response.request, + response=response, + ) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=error, + ): + stream_error = await _collect_stream_error( + provider, + request, + request_id="REQ405", + ) + + assert ( + "Upstream provider NIM rejected the request method or endpoint (HTTP 405)." + in stream_error.message + ) + assert "Request ID: REQ405" in stream_error.message + + @pytest.mark.asyncio + async def test_stream_with_openai_bad_request_surfaces_upstream_body(self): + """OpenAI SDK bodies should be raised so users can copy exact provider errors.""" + provider = _make_provider() + request = _make_request() + response = httpx.Response( + status_code=400, + request=httpx.Request("POST", "https://example.com/v1/chat/completions"), + ) + body = { + "error": { + "type": "BadRequest", + "message": "Thinking mode does not support this tool_choice", + } + } + error = openai.BadRequestError("Bad Request", response=response, body=body) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=error, + ): + stream_error = await _collect_stream_error( + provider, + request, + request_id="REQ_BODY", + ) + + assert "Upstream provider NIM returned HTTP 400." in stream_error.message + assert "Category: BadRequest" in stream_error.message + assert "Thinking mode does not support this tool_choice" in stream_error.message + assert ( + '{"error":{"type":"BadRequest","message":"Thinking mode does not support this tool_choice"}}' + in stream_error.message + ) + assert "Request ID: REQ_BODY" in stream_error.message + + @pytest.mark.asyncio + async def test_error_after_native_tool_call_failure_includes_body(self): + """Detailed failure data survives after the provider closes tool state.""" + provider = _make_provider() + request = _make_request() + tool_chunk = _make_tool_calls_chunk( + name="echo_smoke", arguments="{}", tool_id="call_body", index=0 + ) + response = httpx.Response( + status_code=400, + request=httpx.Request("POST", "https://example.com/v1/chat/completions"), + ) + body = {"error": {"message": "bad after tool"}} + error = openai.BadRequestError("Bad Request", response=response, body=body) + stream_mock = AsyncStreamMock([tool_chunk], error=error) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ): + events, stream_error = await _collect_stream_and_error( + provider, + request, + request_id="REQ_TOOL_BODY", + ) + + event_text = "".join(events) + parsed = parse_sse_text(event_text) + assert "tool_use" in event_text + assert parsed[-1].event == "content_block_stop" + assert "event: error\n" not in event_text + assert "bad after tool" not in event_text + assert "Request ID: REQ_TOOL_BODY" not in event_text + assert "message_stop" not in event_text + assert "bad after tool" in stream_error.message + assert "Request ID: REQ_TOOL_BODY" in stream_error.message + _assert_error_not_in_text_deltas_after_tool(events, "bad after tool") + + @pytest.mark.asyncio + async def test_clean_eof_after_complete_tool_call_salvages_tool_use(self): + """A complete tool JSON payload missing finish_reason is committed as tool_use.""" + provider = _make_provider() + request = _make_request() + tool_chunk = _make_tool_calls_chunk( + name="echo_smoke", arguments='{"message":"ok"}', tool_id="call_eof" + ) + stream_mock = AsyncStreamMock([tool_chunk]) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ): + events = await _collect_stream(provider, request) + + parsed = parse_sse_text("".join(events)) + assert parsed[-1].event == "message_stop" + assert any( + event.event == "message_delta" + and event.data.get("delta", {}).get("stop_reason") == "tool_use" + for event in parsed + ) + assert not any(event.event == "error" for event in parsed) + + @pytest.mark.asyncio + @pytest.mark.parametrize("finish_reason", ["tool_calls", "stop"]) + async def test_heuristic_only_tool_stream_does_not_emit_fallback_text( + self, finish_reason + ): + """Text-parsed tool calls count as emitted tool output when finalizing.""" + provider = _make_provider() + request = _make_request() + heuristic_tool = ( + "● test.py" + "10" + ) + stream_mock = AsyncStreamMock( + [ + _make_chunk(content=heuristic_tool), + _make_chunk(finish_reason=finish_reason), + ] + ) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ): + events = await _collect_stream(provider, request) + + parsed = parse_sse_text("".join(events)) + assert any( + event.event == "content_block_start" + and event.data.get("content_block", {}).get("type") == "tool_use" + for event in parsed + ) + assert not any( + event.event == "content_block_delta" + and event.data.get("delta", {}).get("type") == "text_delta" + and event.data.get("delta", {}).get("text") == " " + for event in parsed + ) + assert any( + event.event == "message_delta" + and event.data.get("delta", {}).get("stop_reason") == "tool_use" + for event in parsed + ) + + @pytest.mark.asyncio + async def test_precommit_openai_holdback_retries_without_leaking_partial(self): + """A retryable early cutoff before holdback commit is retried invisibly.""" + provider = _make_provider() + request = _make_request() + first_stream = AsyncStreamMock( + [_make_chunk(content="hidden")], + error=httpx.ReadError("early cutoff"), + ) + second_stream = AsyncStreamMock( + [ + _make_chunk(content="visible"), + _make_chunk(finish_reason="stop"), + ] + ) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + side_effect=[first_stream, second_stream], + ) as mock_create: + events = await _collect_stream(provider, request) + + event_text = "".join(events) + assert mock_create.await_count == 2 + assert "hidden" not in event_text + assert "visible" in event_text + parsed = parse_sse_text(event_text) + assert parsed[0].event == "message_start" + assert sum(event.event == "message_start" for event in parsed) == 1 + assert parsed[-1].event == "message_stop" + + @pytest.mark.asyncio + async def test_clean_eof_after_text_continues_with_overlap_trim(self): + """A truncated text stream is continued and duplicate overlap is trimmed.""" + provider = _make_provider() + request = _make_request() + stream_mock = AsyncStreamMock([_make_chunk(content="hello wor")]) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + _OpenAIChatStreamRunner, + "_collect_recovery_text", + new_callable=AsyncMock, + return_value=("world", ""), + ), + ): + events = await _collect_stream(provider, request) + + parsed = parse_sse_text("".join(events)) + text_deltas = [ + event.data.get("delta", {}).get("text", "") + for event in parsed + if event.event == "content_block_delta" + ] + assert text_deltas == ["hello wor", "ld"] + assert "".join(text_deltas) == "hello world" + assert any( + event.event == "message_delta" + and event.data.get("delta", {}).get("stop_reason") == "end_turn" + for event in parsed + ) + assert not any(event.event == "error" for event in parsed) + + @pytest.mark.asyncio + async def test_recovery_collect_text_requires_finish_reason(self): + """Recovery collectors reject truncated OpenAI-chat continuation streams.""" + streams = [ + ClosableAsyncStreamMock([_make_chunk(content=f"world {index}")]) + for index in range(MIDSTREAM_RECOVERY_ATTEMPTS) + ] + create_stream = AsyncMock(side_effect=[(stream, {}) for stream in streams]) + provider = _make_provider() + runner = _make_stream_runner(provider) + + with ( + patch.object(provider, "_create_stream", create_stream), + pytest.raises(TruncatedProviderStreamError), + ): + await runner._collect_recovery_text({"messages": []}) + + assert create_stream.await_count == MIDSTREAM_RECOVERY_ATTEMPTS + assert all(stream.closed for stream in streams) + + @pytest.mark.asyncio + async def test_recovery_collect_text_closes_retryable_failed_streams(self): + """Recovery collectors close failed stream attempts before retrying.""" + streams = [ + ClosableAsyncStreamMock( + [_make_chunk(content=f"partial {index}")], + error=TimeoutError("recovery cutoff"), + ) + for index in range(MIDSTREAM_RECOVERY_ATTEMPTS) + ] + create_stream = AsyncMock(side_effect=[(stream, {}) for stream in streams]) + provider = _make_provider() + runner = _make_stream_runner(provider) + + with ( + patch.object(provider, "_create_stream", create_stream), + pytest.raises(TimeoutError), + ): + await runner._collect_recovery_text({"messages": []}) + + assert create_stream.await_count == MIDSTREAM_RECOVERY_ATTEMPTS + assert all(stream.closed for stream in streams) + + @pytest.mark.asyncio + async def test_recovery_collect_text_accepts_finish_reason(self): + """Recovery collectors return text only after the upstream terminal marker.""" + stream = ClosableAsyncStreamMock( + [ + _make_chunk(content="world"), + _make_chunk(finish_reason="stop"), + ] + ) + create_stream = AsyncMock( + return_value=( + stream, + {}, + ) + ) + provider = _make_provider() + runner = _make_stream_runner(provider) + + with patch.object(provider, "_create_stream", create_stream): + result = await runner._collect_recovery_text({"messages": []}) + + assert result == ("world", "") + assert stream.closed is True + + def test_text_recovery_body_preserves_thinking_context(self): + """Continuation prompts include emitted thinking without provider-specific fields.""" + body = { + "messages": [{"role": "user", "content": "hello"}], + "tools": [{"name": "Read"}], + "tool_choice": {"type": "auto"}, + } + + recovery_body = make_text_recovery_body( + body, + partial_text="visible answer", + partial_thinking="hidden reasoning", + ) + + assert "tools" not in recovery_body + assert "tool_choice" not in recovery_body + assert recovery_body["messages"][-2] == { + "role": "assistant", + "content": "visible answer", + } + recovery_prompt = recovery_body["messages"][-1] + assert recovery_prompt["role"] == "user" + assert "hidden reasoning" in recovery_prompt["content"] + assert "reasoning_content" not in recovery_prompt + + @pytest.mark.asyncio + async def test_openai_text_recovery_passes_thinking_context(self): + """OpenAI-chat recovery call sites seed emitted thinking in the prompt.""" + runner = _make_stream_runner( + _make_provider(), request=_make_request(), request_id="req_recovery" + ) + ledger = AnthropicStreamLedger("msg_recovery", "model") + ledger.start_thinking_block() + ledger.emit_thinking_delta("hidden reasoning") + list(ledger.ensure_text_block()) + ledger.emit_text_delta("visible answer") + + with patch.object( + runner, + "_collect_recovery_text", + new_callable=AsyncMock, + return_value=("visible answer done", "hidden reasoning more"), + ) as mock_collect: + events = await runner._recovery_events( + body={"messages": [{"role": "user", "content": "hello"}]}, + ledger=ledger, + error=TimeoutError("cutoff"), + tool_argument_alias_buffers={}, + ) + + assert events is not None + assert mock_collect.await_args is not None + recovery_body = mock_collect.await_args.args[0] + assert "hidden reasoning" in recovery_body["messages"][-1]["content"] + + @pytest.mark.asyncio + async def test_primary_stream_closes_when_iteration_fails(self): + """OpenAI-chat main streams close after iterator failures.""" + provider = _make_provider() + request = _make_request() + stream = ClosableAsyncStreamMock( + [_make_chunk(content="partial")], + error=ValueError("provider stream failed"), + ) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream, + ): + error = await _collect_stream_error(provider, request) + + assert stream.closed is True + assert "provider stream failed" in error.message.lower() + + @pytest.mark.asyncio + async def test_truncated_recovery_stream_closes_block_then_raises(self): + """Partial recovery bytes never become success or provider-owned wire errors.""" + provider = _make_provider() + request = _make_request() + original_text = "hello wor" + ("x" * 70_000) + original_stream = AsyncStreamMock([_make_chunk(content=original_text)]) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=original_stream, + ) as mock_create, + patch.object( + _OpenAIChatStreamRunner, + "_collect_recovery_text", + new_callable=AsyncMock, + side_effect=TruncatedProviderStreamError( + "Recovery stream ended without finish_reason." + ), + ) as mock_collect, + ): + events, error = await _collect_stream_and_error(provider, request) + + event_text = "".join(events) + assert mock_create.await_count == 1 + assert mock_collect.await_count == 1 + assert original_text in event_text + assert "world" not in event_text + assert "Provider stream ended without finish_reason." in error.message + assert "Provider stream ended without finish_reason." not in event_text + parsed = parse_sse_text(event_text) + assert parsed[-1].event == "content_block_stop" + assert not any(event.event == "error" for event in parsed) + assert not any(event.event == "message_stop" for event in parsed) + assert not any( + event.event == "content_block_delta" + and event.data.get("delta", {}).get("text") == "ld" + for event in parse_sse_text(event_text) + ) + + @pytest.mark.asyncio + async def test_incomplete_tool_call_repair_appends_schema_valid_suffix(self): + """A truncated tool JSON prefix is repaired append-only before tool_use tail.""" + provider = _make_provider() + request = _make_request( + tools=[ + { + "name": "echo_smoke", + "description": "Echo", + "input_schema": { + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + "additionalProperties": False, + }, + } + ] + ) + tool_chunk = _make_tool_calls_chunk( + name="echo_smoke", arguments='{"message":', tool_id="call_repair" + ) + stream_mock = AsyncStreamMock([tool_chunk]) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + _OpenAIChatStreamRunner, + "_collect_recovery_text", + new_callable=AsyncMock, + return_value=('"ok"}', ""), + ), + ): + events = await _collect_stream(provider, request) + + event_text = "".join(events) + parsed = parse_sse_text(event_text) + assert '"partial_json": "\\"ok\\"}"' in event_text + assert any( + event.event == "message_delta" + and event.data.get("delta", {}).get("stop_reason") == "tool_use" + for event in parsed + ) + assert not any(event.event == "error" for event in parsed) + + @pytest.mark.asyncio + async def test_stream_rate_limited_retries_via_execute_with_retry(self): + """When rate limited, execute_with_retry handles retries transparently.""" + provider = _make_provider() + request = _make_request() + + chunk1 = _make_chunk(content="Response") + chunk2 = _make_chunk(finish_reason="stop") + stream_mock = AsyncStreamMock([chunk1, chunk2]) + + with patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ): + # Mock execute_with_retry to pass through to the actual function + async def _passthrough(fn, *args, **kwargs): + return await fn(*args, **kwargs) + + with patch.object( + provider._rate_limiter, + "execute_with_retry", + new_callable=AsyncMock, + side_effect=_passthrough, + ): + events = await _collect_stream(provider, request) + + event_text = "".join(events) + assert "Response" in event_text + + +class TestProcessToolCall: + """Tests for OpenAI tool-call assembly.""" + + def test_heuristic_tool_use_sse_marks_committed_tool_output(self): + """Heuristic tool blocks are emitted content, even without OpenAI tool state.""" + from free_claude_code.core.anthropic import AnthropicStreamLedger + + ledger = AnthropicStreamLedger("msg_test", "test-model") + events = list( + iter_heuristic_tool_use_sse( + ledger, + { + "id": "toolu_heuristic", + "name": "Read", + "input": {"path": "test.py"}, + }, + ) + ) + + event_text = "".join(events) + assert "tool_use" in event_text + assert ledger.has_emitted_tool_block() + assert has_committed_sse_output(ledger) + + def test_tool_call_with_id(self): + """Tool call with id starts a tool block.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + tc = { + "index": 0, + "id": "call_123", + "function": {"name": "search", "arguments": '{"q": "test"}'}, + } + events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) + event_text = "".join(events) + assert "tool_use" in event_text + assert "search" in event_text + assert "call_123" in event_text + + def test_tool_call_id_arrives_before_name_still_emits_id_and_name(self): + """Split-stream tool: id (no name) then name then args; id preserved on start.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + t1 = { + "index": 0, + "id": "call_split", + "function": {"name": None, "arguments": ""}, + } + t2 = { + "index": 0, + "id": "call_split", + "function": {"name": "Grep", "arguments": ""}, + } + t3 = { + "index": 0, + "id": "call_split", + "function": {"name": None, "arguments": "{}"}, + } + b1 = "".join(_make_tool_assembler(provider).process_tool_call(t1, sse)) + b2 = "".join(_make_tool_assembler(provider).process_tool_call(t2, sse)) + b3 = "".join(_make_tool_assembler(provider).process_tool_call(t3, sse)) + combined = b1 + b2 + b3 + assert "call_split" in combined + assert "Grep" in combined + assert b1 == "" + + def test_tool_call_arguments_buffered_until_name(self): + """Argument deltas before tool name are emitted after the block starts.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + t1 = { + "index": 0, + "id": "call_buf", + "function": {"name": None, "arguments": '{"x":'}, + } + t2 = { + "index": 0, + "id": "call_buf", + "function": {"name": "Read", "arguments": "1}"}, + } + b1 = "".join(_make_tool_assembler(provider).process_tool_call(t1, sse)) + b2 = "".join(_make_tool_assembler(provider).process_tool_call(t2, sse)) + assert b1 == "" + combined = b2 + assert "Read" in combined + assert "call_buf" in combined + assert '{"x":' in combined or "partial_json" in combined + + def test_tool_call_without_id_generates_uuid(self): + """Tool call without id generates a uuid-based id.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + tc = { + "index": 0, + "id": None, + "function": {"name": "test", "arguments": "{}"}, + } + events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) + event_text = "".join(events) + assert "tool_" in event_text + + def test_task_tool_forces_background_false(self): + """Task tool with run_in_background=true is forced to false.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + args = json.dumps({"run_in_background": True, "prompt": "test"}) + tc = { + "index": 0, + "id": "call_task", + "function": {"name": "Task", "arguments": args}, + } + events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) + event_text = "".join(events) + # The intercepted args should have run_in_background=false + assert "false" in event_text.lower() + + def test_task_tool_chunked_args_forces_background_false(self): + """Chunked Task args are buffered until valid JSON, then forced to false.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + tc1 = { + "index": 0, + "id": "call_task_chunked", + "function": {"name": "Task", "arguments": '{"run_in_background": true,'}, + } + tc2 = { + "index": 0, + "id": "call_task_chunked", + "function": {"name": None, "arguments": ' "prompt": "test"}'}, + } + + events1 = list(_make_tool_assembler(provider).process_tool_call(tc1, sse)) + assert len(events1) > 0 + assert "false" not in "".join(events1).lower() + + events2 = list(_make_tool_assembler(provider).process_tool_call(tc2, sse)) + event_text = "".join(events1 + events2) + assert "false" in event_text.lower() + + def test_task_tool_invalid_json_logs_warning_on_flush(self, caplog): + """Invalid JSON args for Task tool emits {} on flush and logs a warning.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + tc = { + "index": 0, + "id": "call_task2", + "function": {"name": "Task", "arguments": "not json"}, + } + events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) + assert len(events) > 0 + + with caplog.at_level("WARNING"): + flushed = list(_make_tool_assembler(provider).flush_task_arg_buffers(sse)) + assert len(flushed) > 0 + assert "{}" in "".join(flushed) + assert any("Task args invalid JSON" in r.message for r in caplog.records) + + def test_negative_tool_index_fallback(self): + """tc_index < 0 uses len(tool_indices) as fallback.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + tc = { + "index": -1, + "id": "call_neg", + "function": {"name": "test", "arguments": "{}"}, + } + events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) + # Should not crash, should still emit events + assert len(events) > 0 + + def test_none_tool_index_defaults_to_zero(self): + """Gemini may stream tool_call deltas with a null index.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + tc = { + "index": None, + "id": "call_none", + "function": {"name": "test", "arguments": "{}"}, + } + events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) + event_text = "".join(events) + + assert "tool_use" in event_text + assert "call_none" in event_text + + def test_tool_args_emitted_as_delta(self): + """Arguments are emitted as input_json_delta events.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + tc = { + "index": 0, + "id": "call_args", + "function": {"name": "grep", "arguments": '{"pattern": "test"}'}, + } + events = list(_make_tool_assembler(provider).process_tool_call(tc, sse)) + event_text = "".join(events) + assert "input_json_delta" in event_text + + +class TestStreamChunkEdgeCases: + """Tests for edge cases in stream chunk handling.""" + + @pytest.mark.asyncio + async def test_stream_chunk_with_empty_choices_skipped(self): + """Chunk with choices=[] is skipped without crashing.""" + provider = _make_provider() + request = _make_request() + + empty_choices_chunk = MagicMock() + empty_choices_chunk.choices = [] + empty_choices_chunk.usage = None + + finish_chunk = _make_chunk(finish_reason="stop") + stream_mock = AsyncStreamMock([empty_choices_chunk, finish_chunk]) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + events = await _collect_stream(provider, request) + + event_text = "".join(events) + assert "message_start" in event_text + assert "message_stop" in event_text + + @pytest.mark.asyncio + async def test_stream_chunk_with_none_delta_handled(self): + """Chunk with choice.delta=None is handled defensively.""" + provider = _make_provider() + request = _make_request() + + none_delta_chunk = MagicMock() + none_delta_chunk.usage = None + choice = MagicMock() + choice.delta = None + choice.finish_reason = None + none_delta_chunk.choices = [choice] + + finish_chunk = _make_chunk(finish_reason="stop") + stream_mock = AsyncStreamMock([none_delta_chunk, finish_chunk]) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + events = await _collect_stream(provider, request) + + event_text = "".join(events) + assert "message_start" in event_text + assert "message_stop" in event_text + + @pytest.mark.asyncio + async def test_stream_generator_cleanup_on_exception(self): + """When stream raises mid-iteration, message_stop still emitted.""" + provider = _make_provider() + request = _make_request() + + chunk1 = _make_chunk(content="Partial") + stream_mock = AsyncStreamMock( + [chunk1], error=ConnectionResetError("Connection reset") + ) + + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + error = await _collect_stream_error(provider, request) + + assert "Connection reset" in error.message + + def test_stream_malformed_tool_args_chunked(self): + """Chunked tool args that never form valid JSON are flushed with {}.""" + provider = _make_provider() + from free_claude_code.core.anthropic import AnthropicStreamLedger + + sse = AnthropicStreamLedger("msg_test", "test-model") + tc1 = { + "index": 0, + "id": "call_malformed", + "function": {"name": "Task", "arguments": '{"broken":'}, + } + tc2 = { + "index": 0, + "id": "call_malformed", + "function": {"name": None, "arguments": " never valid }"}, + } + + events1 = list(_make_tool_assembler(provider).process_tool_call(tc1, sse)) + events2 = list(_make_tool_assembler(provider).process_tool_call(tc2, sse)) + flushed = list(_make_tool_assembler(provider).flush_task_arg_buffers(sse)) + + event_text = "".join(events1 + events2 + flushed) + assert "tool_use" in event_text + assert "{}" in event_text + + +@pytest.mark.asyncio +async def test_openai_compat_stream_ends_with_contract_when_tool_name_never_arrives() -> ( + None +): + """Nameless / incomplete tool-call buffer must not break Anthropic stream contract.""" + provider = _make_provider() + request = _make_request() + tc0 = SimpleNamespace( + index=0, + id="call_inc", + function=SimpleNamespace(name=None, arguments="{}"), + ) + stream_mock = AsyncStreamMock([_make_chunk(tool_calls=[tc0])]) + with ( + patch.object( + provider._client.chat.completions, + "create", + new_callable=AsyncMock, + return_value=stream_mock, + ), + patch.object( + provider._rate_limiter, + "wait_if_blocked", + new_callable=AsyncMock, + return_value=False, + ), + ): + error = await _collect_stream_error(provider, request) + + assert "Provider stream ended without finish_reason." in error.message diff --git a/tests/providers/test_subagent_interception.py b/tests/providers/test_subagent_interception.py new file mode 100644 index 0000000..f8e1e99 --- /dev/null +++ b/tests/providers/test_subagent_interception.py @@ -0,0 +1,67 @@ +import json +from unittest.mock import MagicMock + +import pytest + +from free_claude_code.config.nim import NimSettings +from free_claude_code.config.provider_catalog import NVIDIA_NIM_DEFAULT_BASE +from free_claude_code.core.anthropic import StreamBlockLedger +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from free_claude_code.providers.openai_chat.tool_calls import ( + OpenAIToolCallAssembler, +) +from tests.providers.support import passthrough_rate_limiter + + +@pytest.mark.asyncio +async def test_task_tool_interception(): + # Setup provider + config = ProviderConfig(api_key="test", base_url=NVIDIA_NIM_DEFAULT_BASE) + provider = NvidiaNimProvider( + config, + nim_settings=NimSettings(), + rate_limiter=passthrough_rate_limiter(), + ) + + # Mock request and stream ledger with real StreamBlockLedger + request = MagicMock() + request.model = "test-model" + + sse = MagicMock() + sse.blocks = StreamBlockLedger() + + # Tool call data (Task tool) + tc = { + "index": 0, + "id": "tool_123", + "function": { + "name": "Task", + "arguments": json.dumps( + { + "description": "test task", + "prompt": "do something", + "run_in_background": True, + } + ), + }, + } + + tool_calls = OpenAIToolCallAssembler( + record_extra_content=provider._record_tool_call_extra_content + ) + + # Call the assembler (consume generator to trigger side effects) + list(tool_calls.process_tool_call(tc, sse)) + + # Find the emit_tool_delta call and check args + calls = sse.emit_tool_delta.call_args_list + assert len(calls) > 0 + args_passed = json.loads(calls[0][0][1]) + assert args_passed["run_in_background"] is False + + +if __name__ == "__main__": + import asyncio + + asyncio.run(test_task_tool_interception()) diff --git a/tests/providers/test_vercel.py b/tests/providers/test_vercel.py new file mode 100644 index 0000000..d3c700d --- /dev/null +++ b/tests/providers/test_vercel.py @@ -0,0 +1,166 @@ +"""Tests for Vercel AI Gateway provider.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.config.provider_catalog import VERCEL_AI_GATEWAY_DEFAULT_BASE +from free_claude_code.providers.base import ProviderConfig +from tests.providers.request_factory import make_messages_request +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +def make_request(**overrides): + return make_messages_request("openai/gpt-5.5", **overrides) + + +@pytest.fixture +def vercel_config(): + return ProviderConfig( + api_key="test_vercel_key", + base_url=VERCEL_AI_GATEWAY_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ) + + +@pytest.fixture +def vercel_provider(vercel_config): + return profiled_provider( + "vercel", + vercel_config, + rate_limiter=passthrough_rate_limiter(), + ) + + +def test_default_base_url_constant(): + assert VERCEL_AI_GATEWAY_DEFAULT_BASE == "https://ai-gateway.vercel.sh/v1" + + +def test_init_uses_default_base_url_and_api_key(vercel_config): + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI" + ) as mock_openai: + provider = profiled_provider( + "vercel", + vercel_config, + rate_limiter=passthrough_rate_limiter(), + ) + + assert provider._api_key == "test_vercel_key" + assert provider._base_url == VERCEL_AI_GATEWAY_DEFAULT_BASE + mock_openai.assert_called_once() + + +def test_init_strips_trailing_slash(vercel_config): + config = replace(vercel_config, base_url=f"{VERCEL_AI_GATEWAY_DEFAULT_BASE}/") + + with patch("free_claude_code.providers.openai_chat.provider.AsyncOpenAI"): + provider = profiled_provider( + "vercel", + config, + rate_limiter=passthrough_rate_limiter(), + ) + + assert provider._base_url == VERCEL_AI_GATEWAY_DEFAULT_BASE + + +def test_build_request_body_keeps_max_tokens(vercel_provider): + with patch( + "free_claude_code.providers.openai_chat.request_policy.build_base_request_body" + ) as mock_convert: + mock_convert.return_value = { + "model": "openai/gpt-5.5", + "messages": [{"role": "user", "name": "alice", "content": "hi"}], + "max_tokens": 42, + } + + body = vercel_provider._build_request_body(make_request()) + + assert body["messages"][0].get("name") == "alice" + assert body["max_tokens"] == 42 + assert "max_completion_tokens" not in body + + +def test_build_request_body_preserves_caller_extra_body(vercel_provider): + req = make_request(extra_body={"providerOptions": {"openai": {"reasoning": "low"}}}) + + body = vercel_provider._build_request_body(req) + + assert body["extra_body"] == {"providerOptions": {"openai": {"reasoning": "low"}}} + + +@pytest.mark.asyncio +async def test_stream_response_text(vercel_provider): + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content="Hello from Vercel", + reasoning_content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + vercel_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event async for event in vercel_provider.stream_response(make_request()) + ] + + assert any( + '"text_delta"' in event and "Hello from Vercel" in event for event in events + ) + + +@pytest.mark.asyncio +async def test_stream_response_reasoning_content(vercel_provider): + mock_chunk = MagicMock() + mock_chunk.choices = [ + MagicMock( + delta=MagicMock( + content=None, + reasoning_content="Thinking via gateway", + tool_calls=None, + ), + finish_reason="stop", + ) + ] + mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10) + + async def mock_stream(): + yield mock_chunk + + with patch.object( + vercel_provider._client.chat.completions, "create", new_callable=AsyncMock + ) as mock_create: + mock_create.return_value = mock_stream() + + events = [ + event async for event in vercel_provider.stream_response(make_request()) + ] + + assert any( + '"thinking_delta"' in event and "Thinking via gateway" in event + for event in events + ) + + +@pytest.mark.asyncio +async def test_cleanup(vercel_provider): + vercel_provider._client = AsyncMock() + + await vercel_provider.cleanup() + + vercel_provider._client.close.assert_called_once() diff --git a/tests/providers/test_wafer.py b/tests/providers/test_wafer.py new file mode 100644 index 0000000..96896a3 --- /dev/null +++ b/tests/providers/test_wafer.py @@ -0,0 +1,158 @@ +"""Tests for the Wafer OpenAI-chat provider.""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.config.provider_catalog import WAFER_DEFAULT_BASE +from free_claude_code.core.anthropic.models import Message, MessagesRequest, Tool +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import ( + OPENAI_CHAT_PROFILES, + OpenAIChatProvider, +) +from free_claude_code.providers.rate_limit import ProviderRateLimiter +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +class CountingWaferProvider(OpenAIChatProvider): + def __init__(self, config: ProviderConfig, *, rate_limiter: ProviderRateLimiter): + super().__init__( + config, + profile=OPENAI_CHAT_PROFILES["wafer"], + rate_limiter=rate_limiter, + ) + self.thinking_checks = 0 + + def _is_thinking_enabled( + self, request: Any, thinking_enabled: bool | None = None + ) -> bool: + self.thinking_checks += 1 + return super()._is_thinking_enabled(request, thinking_enabled) + + +@pytest.fixture +def wafer_config(): + return ProviderConfig( + api_key="test-wafer-key", + base_url=WAFER_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + ) + + +@pytest.fixture +def wafer_provider(wafer_config): + return profiled_provider( + "wafer", + wafer_config, + rate_limiter=passthrough_rate_limiter(), + ) + + +def test_default_base_url(): + assert WAFER_DEFAULT_BASE == "https://pass.wafer.ai/v1" + + +def test_init_uses_openai_chat_provider(wafer_provider): + assert isinstance(wafer_provider, OpenAIChatProvider) + assert wafer_provider._api_key == "test-wafer-key" + assert wafer_provider._base_url == WAFER_DEFAULT_BASE + assert wafer_provider._provider_name == "WAFER" + + +def test_build_request_body_openai_shape_and_defaults(wafer_provider): + request = MessagesRequest.model_validate( + { + "model": "DeepSeek-V4-Pro", + "messages": [Message(role="user", content="Hello")], + "tools": [ + Tool( + name="echo", + description="Echo input", + input_schema={"type": "object", "properties": {}}, + ) + ], + "thinking": {"type": "enabled", "budget_tokens": 2048}, + } + ) + + body = wafer_provider._build_request_body(request) + + assert body["model"] == "DeepSeek-V4-Pro" + assert body["messages"][0] == {"role": "user", "content": "Hello"} + assert body["tools"][0]["function"]["name"] == "echo" + assert body["extra_body"]["thinking"] == {"type": "enabled"} + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + +def test_build_request_body_honors_effective_no_thinking(wafer_provider): + request = MessagesRequest.model_validate( + { + "model": "DeepSeek-V4-Pro", + "messages": [{"role": "user", "content": "Explore the codebase."}], + } + ) + + body = wafer_provider._build_request_body(request, thinking_enabled=False) + + assert body["extra_body"]["thinking"] == {"type": "disabled"} + + +def test_build_request_body_preserves_request_disabled_thinking(wafer_provider): + request = MessagesRequest.model_validate( + { + "model": "DeepSeek-V4-Pro", + "messages": [{"role": "user", "content": "Explore the codebase."}], + "thinking": {"type": "disabled"}, + } + ) + + body = wafer_provider._build_request_body(request, thinking_enabled=True) + + assert body["extra_body"]["thinking"] == {"type": "disabled"} + + +def test_build_request_body_resolves_thinking_once(wafer_config): + provider = CountingWaferProvider( + wafer_config, + rate_limiter=passthrough_rate_limiter(), + ) + request = MessagesRequest.model_validate( + { + "model": "DeepSeek-V4-Pro", + "messages": [{"role": "user", "content": "Explore the codebase."}], + } + ) + + body = provider._build_request_body(request, thinking_enabled=False) + + assert body["extra_body"]["thinking"] == {"type": "disabled"} + assert provider.thinking_checks == 1 + + +@pytest.mark.asyncio +async def test_lists_models_from_openai_models_endpoint(wafer_provider): + wafer_provider._client.models.list = AsyncMock( + return_value=MagicMock( + data=[MagicMock(id="DeepSeek-V4-Pro"), MagicMock(id="MiniMax-M2.7")] + ) + ) + + assert await wafer_provider.list_model_ids() == frozenset( + {"DeepSeek-V4-Pro", "MiniMax-M2.7"} + ) + + wafer_provider._client.models.list.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_cleanup_closes_openai_client(wafer_provider): + wafer_provider._client = MagicMock() + wafer_provider._client.close = AsyncMock() + + await wafer_provider.cleanup() + + wafer_provider._client.close.assert_awaited_once() diff --git a/tests/providers/test_zai.py b/tests/providers/test_zai.py new file mode 100644 index 0000000..364ee6b --- /dev/null +++ b/tests/providers/test_zai.py @@ -0,0 +1,123 @@ +"""Tests for the Z.ai OpenAI-chat Coding Plan provider.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from free_claude_code.application.errors import InvalidRequestError +from free_claude_code.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from free_claude_code.config.provider_catalog import ZAI_DEFAULT_BASE +from free_claude_code.core.anthropic.models import Message, MessagesRequest +from free_claude_code.providers.base import ProviderConfig +from free_claude_code.providers.openai_chat import OpenAIChatProvider +from tests.providers.support import passthrough_rate_limiter, profiled_provider + + +@pytest.fixture +def zai_provider(): + return profiled_provider( + "zai", + ProviderConfig( + api_key="test_zai_key", + base_url=ZAI_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + enable_thinking=True, + ), + rate_limiter=passthrough_rate_limiter(), + ) + + +def test_init_uses_openai_chat_coding_endpoint(zai_provider): + assert isinstance(zai_provider, OpenAIChatProvider) + assert zai_provider._api_key == "test_zai_key" + assert zai_provider._base_url == "https://api.z.ai/api/coding/paas/v4" + + +def test_build_request_body_openai_chat(zai_provider): + request = MessagesRequest( + model="glm-5.2", + max_tokens=100, + messages=[Message(role="user", content="Hello")], + ) + + body = zai_provider._build_request_body(request) + + assert body["model"] == "glm-5.2" + assert body["max_tokens"] == 100 + assert body["messages"] == [{"role": "user", "content": "Hello"}] + assert body["extra_body"]["thinking"] == { + "type": "enabled", + "clear_thinking": False, + } + + +def test_build_request_body_default_max_tokens(zai_provider): + request = MessagesRequest( + model="m", + messages=[Message(role="user", content="x")], + ) + + body = zai_provider._build_request_body(request) + + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + +def test_build_request_body_rejects_caller_extra_body(zai_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "extra_body": {"x": 1}, + } + ) + + with pytest.raises(InvalidRequestError, match=r"Z\.ai Chat Completions"): + zai_provider._build_request_body(request) + + +def test_build_request_body_disables_zai_thinking(zai_provider): + request = MessagesRequest.model_validate( + { + "model": "m", + "messages": [{"role": "user", "content": "x"}], + "thinking": {"type": "disabled"}, + } + ) + + body = zai_provider._build_request_body(request) + + assert body["extra_body"]["thinking"] == {"type": "disabled"} + + +def test_build_request_body_replays_prior_reasoning_content(zai_provider): + request = MessagesRequest.model_validate( + { + "model": "glm-5.2", + "messages": [ + { + "role": "assistant", + "content": [{"type": "thinking", "thinking": "prior"}], + }, + {"role": "user", "content": "continue"}, + ], + } + ) + + body = zai_provider._build_request_body(request) + + assert body["messages"][0]["reasoning_content"] == "prior" + assert body["extra_body"]["thinking"] == { + "type": "enabled", + "clear_thinking": False, + } + + +@pytest.mark.asyncio +async def test_cleanup_closes_openai_client(zai_provider): + zai_provider._client = MagicMock() + zai_provider._client.close = AsyncMock() + + await zai_provider.cleanup() + + zai_provider._client.close.assert_awaited_once() diff --git a/tests/runtime/test_application_runtime.py b/tests/runtime/test_application_runtime.py new file mode 100644 index 0000000..0113cf0 --- /dev/null +++ b/tests/runtime/test_application_runtime.py @@ -0,0 +1,937 @@ +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import free_claude_code.messaging.session.persistence as persistence_module +from free_claude_code.config.admin.persistence import PreparedAdminUpdate +from free_claude_code.config.settings import Settings +from free_claude_code.messaging.command_context import StopOutcome +from free_claude_code.messaging.platforms.ports import ( + InboundMessageHandler, + MessagingPlatformComponents, + MessagingStartupNotice, +) +from free_claude_code.messaging.session import SessionStore +from free_claude_code.messaging.workflow import MessagingWorkflow +from free_claude_code.providers.runtime import ProviderRuntime +from free_claude_code.runtime.application import ApplicationRuntime +from free_claude_code.runtime.provider_manager import ProviderRuntimeManager + + +class TrackingRuntime(ProviderRuntime): + def __init__(self, settings: Settings) -> None: + super().__init__(settings) + self.cleanup_calls = 0 + + async def cleanup(self) -> None: + self.cleanup_calls += 1 + await super().cleanup() + + +class TrackingFactory: + def __init__(self) -> None: + self.runtimes: list[TrackingRuntime] = [] + self.fail = False + self.events: list[str] = [] + + def __call__(self, settings: Settings) -> ProviderRuntime: + self.events.append(f"construct:{settings.model}") + if self.fail: + raise RuntimeError("candidate failed") + runtime = TrackingRuntime(settings) + self.runtimes.append(runtime) + return runtime + + +class TrackingTranscriber: + def __init__(self, events: list[str]) -> None: + self.events = events + self.close_calls = 0 + + async def transcribe(self, file_path: Path) -> str: + assert isinstance(file_path, Path) + return "transcribed" + + async def close(self) -> None: + self.close_calls += 1 + self.events.append("transcriber.close") + + +class FailingTranscriber(TrackingTranscriber): + async def close(self) -> None: + await super().close() + raise RuntimeError("transcriber close failed") + + +class CancelledTranscriber(TrackingTranscriber): + async def close(self) -> None: + await super().close() + raise asyncio.CancelledError + + +class CancellingOnceTranscriber(TrackingTranscriber): + async def close(self) -> None: + await super().close() + if self.close_calls == 1: + raise asyncio.CancelledError + + +class TrackingMessagingRuntime: + name = "tracking" + + def __init__( + self, + events: list[str], + *, + fail_quiesce_once: bool = False, + fail_close_once: bool = False, + ) -> None: + self.events = events + self.fail_quiesce_once = fail_quiesce_once + self.fail_close_once = fail_close_once + + async def start(self) -> None: + self.events.append("messaging.start") + + async def quiesce(self) -> None: + self.events.append("messaging.quiesce") + if self.fail_quiesce_once: + self.fail_quiesce_once = False + raise RuntimeError("quiesce failed") + + async def close(self) -> None: + self.events.append("messaging.close") + if self.fail_close_once: + self.fail_close_once = False + raise RuntimeError("close failed") + + def on_message(self, handler: InboundMessageHandler) -> None: + assert callable(handler) + + @property + def is_connected(self) -> bool: + return True + + +class PersistentlyFailingMessagingRuntime(TrackingMessagingRuntime): + def __init__(self, events: list[str]) -> None: + super().__init__(events) + self.fail_quiesce = True + + async def quiesce(self) -> None: + self.events.append("messaging.quiesce") + if self.fail_quiesce: + raise RuntimeError("quiesce failed") + + +def _settings(model: str, *, port: int = 8082) -> Settings: + return Settings().model_copy(update={"model": model, "port": port}) + + +def _prepared( + settings: Settings, + tmp_path, + *, + pending_fields: tuple[str, ...] = (), +) -> PreparedAdminUpdate: + return PreparedAdminUpdate( + target_values={"MODEL": settings.model}, + settings=settings, + errors=(), + pending_fields=pending_fields, + path=tmp_path / ".env", + ) + + +def _applied_response(pending_fields: tuple[str, ...] = ()) -> dict[str, object]: + return { + "applied": True, + "valid": True, + "errors": [], + "env_preview": "MODEL=updated\n", + "path": ".env", + "pending_fields": list(pending_fields), + } + + +@pytest.mark.asyncio +async def test_stop_all_maps_messaging_outcome_to_application_count() -> None: + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + workflow = MagicMock() + workflow.stop_all_tasks = AsyncMock( + return_value=StopOutcome( + cancelled_count=3, + status_feedback_scopes=frozenset(), + fallback_required=False, + ) + ) + runtime._messaging_workflow = workflow + + result = await runtime.stop_all() + + assert result is not None + assert result.cancelled_count == 3 + workflow.stop_all_tasks.assert_awaited_once() + await manager.close() + + +@pytest.mark.asyncio +async def test_provider_apply_constructs_before_commit_then_publishes(tmp_path) -> None: + factory = TrackingFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/old"), + runtime_factory=factory, + ) + runtime = ApplicationRuntime(manager, transcriber=None) + prepared = _prepared(_settings("nvidia_nim/new"), tmp_path) + factory.events.clear() + + def commit(_prepared_update: PreparedAdminUpdate) -> dict[str, object]: + factory.events.append("commit") + assert manager.current_generation_id == 1 + return _applied_response() + + with ( + patch( + "free_claude_code.runtime.application.prepare_admin_update", + return_value=prepared, + ), + patch( + "free_claude_code.runtime.application.commit_prepared_admin_update", + side_effect=commit, + ), + ): + result = await runtime.apply_admin_config({"MODEL": "nvidia_nim/new"}) + + assert factory.events == ["construct:nvidia_nim/new", "commit"] + assert manager.current_generation_id == 2 + assert manager.current_settings().model == "nvidia_nim/new" + assert result["restart"] == { + "required": False, + "automatic": False, + "admin_url": None, + "fields": [], + } + await manager.close() + + +@pytest.mark.asyncio +async def test_candidate_failure_never_commits_and_preserves_current(tmp_path) -> None: + factory = TrackingFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/old"), + runtime_factory=factory, + ) + runtime = ApplicationRuntime(manager, transcriber=None) + prepared = _prepared(_settings("nvidia_nim/new"), tmp_path) + factory.fail = True + + with ( + patch( + "free_claude_code.runtime.application.prepare_admin_update", + return_value=prepared, + ), + patch( + "free_claude_code.runtime.application.commit_prepared_admin_update" + ) as commit, + pytest.raises(RuntimeError, match="candidate failed"), + ): + await runtime.apply_admin_config({"MODEL": "nvidia_nim/new"}) + + commit.assert_not_called() + assert manager.current_generation_id == 1 + assert manager.current_settings().model == "nvidia_nim/old" + await manager.close() + + +@pytest.mark.asyncio +async def test_persistence_failure_closes_candidate_and_preserves_current( + tmp_path, +) -> None: + factory = TrackingFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/old"), + runtime_factory=factory, + ) + runtime = ApplicationRuntime(manager, transcriber=None) + prepared = _prepared(_settings("nvidia_nim/new"), tmp_path) + + with ( + patch( + "free_claude_code.runtime.application.prepare_admin_update", + return_value=prepared, + ), + patch( + "free_claude_code.runtime.application.commit_prepared_admin_update", + side_effect=OSError("disk full"), + ), + pytest.raises(OSError, match="disk full"), + ): + await runtime.apply_admin_config({"MODEL": "nvidia_nim/new"}) + + assert manager.current_generation_id == 1 + assert factory.runtimes[0].cleanup_calls == 0 + assert factory.runtimes[1].cleanup_calls == 1 + await manager.close() + + +@pytest.mark.asyncio +async def test_restart_required_apply_commits_without_hot_publication(tmp_path) -> None: + factory = TrackingFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/old"), + runtime_factory=factory, + ) + restart = AsyncMock() + runtime = ApplicationRuntime( + manager, + transcriber=None, + restart_callback=restart, + ) + prepared = _prepared( + _settings("nvidia_nim/old", port=9090), + tmp_path, + pending_fields=("PORT",), + ) + + with ( + patch( + "free_claude_code.runtime.application.prepare_admin_update", + return_value=prepared, + ), + patch( + "free_claude_code.runtime.application.commit_prepared_admin_update", + return_value=_applied_response(("PORT",)), + ) as commit, + ): + result = await runtime.apply_admin_config({"PORT": "9090"}) + + commit.assert_called_once_with(prepared) + assert manager.current_generation_id == 1 + assert len(factory.runtimes) == 1 + assert result["restart"] == { + "required": True, + "automatic": True, + "admin_url": "http://127.0.0.1:9090/admin", + "fields": ["PORT"], + } + restart.assert_not_awaited() + await runtime.request_restart() + restart.assert_awaited_once() + await manager.close() + + +@pytest.mark.asyncio +async def test_close_drains_messaging_before_transcriber_and_is_idempotent() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + transcriber = TrackingTranscriber(events) + runtime = ApplicationRuntime(manager, transcriber=transcriber) + runtime._messaging_runtime = TrackingMessagingRuntime(events) + workflow = MagicMock() + workflow.close = AsyncMock(side_effect=lambda: events.append("workflow.close")) + runtime._messaging_workflow = workflow + runtime._cli_manager = MagicMock() + + assert runtime.is_closed is False + assert await runtime.close() is True + assert await runtime.close() is True + + assert events == [ + "messaging.quiesce", + "workflow.close", + "messaging.close", + "transcriber.close", + ] + assert transcriber.close_calls == 1 + assert runtime._transcriber is None + assert runtime._messaging_runtime is None + assert runtime._messaging_workflow is None + assert runtime.is_closed is True + + +@pytest.mark.asyncio +async def test_close_retains_transcriber_ownership_when_close_fails() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + transcriber = FailingTranscriber(events) + runtime = ApplicationRuntime(manager, transcriber=transcriber) + runtime._messaging_runtime = TrackingMessagingRuntime(events) + + assert await runtime.close() is False + + assert events == [ + "messaging.quiesce", + "messaging.close", + "transcriber.close", + ] + assert transcriber.close_calls == 1 + assert runtime._transcriber is transcriber + assert runtime._closed is False + await manager.close() + + +@pytest.mark.asyncio +async def test_close_retries_runtime_before_closing_later_resources() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + transcriber = TrackingTranscriber(events) + runtime = ApplicationRuntime(manager, transcriber=transcriber) + messaging = TrackingMessagingRuntime(events, fail_close_once=True) + runtime._messaging_runtime = messaging + + assert await runtime.close() is False + + assert events == ["messaging.quiesce", "messaging.close"] + assert runtime._messaging_runtime is messaging + assert runtime._transcriber is transcriber + assert runtime._closed is False + + assert await runtime.close() is True + + assert events == [ + "messaging.quiesce", + "messaging.close", + "messaging.quiesce", + "messaging.close", + "transcriber.close", + ] + assert runtime._messaging_runtime is None + assert runtime._transcriber is None + assert runtime._closed is True + + +@pytest.mark.asyncio +async def test_close_retries_workflow_close_before_closing_delivery() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + messaging = TrackingMessagingRuntime(events) + workflow = MagicMock() + close_calls = 0 + + async def close_workflow() -> None: + nonlocal close_calls + close_calls += 1 + if close_calls == 1: + raise RuntimeError("drain failed") + events.append("workflow.close") + + workflow.close = AsyncMock(side_effect=close_workflow) + runtime._messaging_runtime = messaging + runtime._messaging_workflow = workflow + + assert await runtime.close() is False + + assert events == ["messaging.quiesce"] + assert runtime._messaging_runtime is messaging + assert runtime._messaging_workflow is workflow + assert runtime._closed is False + + assert await runtime.close() is True + + assert events == [ + "messaging.quiesce", + "messaging.quiesce", + "workflow.close", + "messaging.close", + ] + assert runtime._closed is True + + +@pytest.mark.asyncio +async def test_close_does_not_drain_workflow_until_ingress_is_quiescent() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + messaging = TrackingMessagingRuntime(events, fail_quiesce_once=True) + workflow = MagicMock() + workflow.close = AsyncMock(side_effect=lambda: events.append("workflow.close")) + runtime._messaging_runtime = messaging + runtime._messaging_workflow = workflow + + assert await runtime.close() is False + + workflow.close.assert_not_awaited() + assert runtime._messaging_runtime is messaging + assert runtime._messaging_workflow is workflow + + assert await runtime.close() is True + + assert events == [ + "messaging.quiesce", + "messaging.quiesce", + "workflow.close", + "messaging.close", + ] + assert runtime._closed is True + + +@pytest.mark.asyncio +async def test_close_retries_failed_persistence_before_closing_delivery() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + messaging = TrackingMessagingRuntime(events) + workflow = MagicMock() + close_calls = 0 + + async def close_workflow() -> None: + nonlocal close_calls + close_calls += 1 + if close_calls == 1: + raise RuntimeError("flush failed") + events.append("workflow.close") + + workflow.close = AsyncMock(side_effect=close_workflow) + runtime._messaging_runtime = messaging + runtime._messaging_workflow = workflow + + await runtime.close() + + assert runtime._messaging_workflow is workflow + assert runtime._messaging_runtime is messaging + assert "messaging.close" not in events + + await runtime.close() + + assert events == [ + "messaging.quiesce", + "messaging.quiesce", + "workflow.close", + "messaging.close", + ] + assert runtime._closed is True + + +@pytest.mark.asyncio +async def test_close_retries_real_workflow_persistence_without_losing_latest_state( + tmp_path: Path, +) -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + messaging = TrackingMessagingRuntime(events) + cli_manager = MagicMock() + cli_manager.stop_all = AsyncMock() + outbound = MagicMock() + store_path = tmp_path / "sessions.json" + real_replace = persistence_module.os.replace + replace_calls = 0 + + def fail_first_replace(source: str, target: str) -> None: + nonlocal replace_calls + replace_calls += 1 + if replace_calls == 1: + raise OSError("replace failed once") + real_replace(source, target) + + with patch.object(persistence_module.threading, "Timer"): + store = SessionStore(storage_path=str(store_path)) + store.record_message_id( + "telegram", + "chat_1", + "old", + "out", + "status", + ) + store.flush_pending_save() + store.record_message_id( + "telegram", + "chat_1", + "latest", + "out", + "status", + ) + workflow = MessagingWorkflow(outbound, cli_manager, store) + runtime._messaging_runtime = messaging + runtime._messaging_workflow = workflow + runtime._cli_manager = cli_manager + + with patch.object( + persistence_module.os, + "replace", + side_effect=fail_first_replace, + ): + assert await runtime.close() is False + + assert runtime._messaging_runtime is messaging + assert runtime._messaging_workflow is workflow + assert runtime._cli_manager is cli_manager + assert runtime.is_closed is False + assert store.dirty is True + assert store.get_tracked_message_ids_for_chat("telegram", "chat_1") == [ + "old", + "latest", + ] + assert SessionStore( + storage_path=str(store_path) + ).get_tracked_message_ids_for_chat("telegram", "chat_1") == ["old"] + + assert await runtime.close() is True + + assert runtime._messaging_runtime is None + assert runtime._messaging_workflow is None + assert runtime._cli_manager is None + assert runtime.is_closed is True + assert store.dirty is False + assert SessionStore( + storage_path=str(store_path) + ).get_tracked_message_ids_for_chat("telegram", "chat_1") == [ + "old", + "latest", + ] + assert events == [ + "messaging.quiesce", + "messaging.quiesce", + "messaging.close", + ] + + +@pytest.mark.asyncio +async def test_cancelled_transcriber_close_retains_ownership() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + transcriber = CancelledTranscriber(events) + runtime = ApplicationRuntime(manager, transcriber=transcriber) + + with pytest.raises(asyncio.CancelledError): + await runtime._cleanup_transcriber() + + assert transcriber.close_calls == 1 + assert runtime._transcriber is transcriber + await manager.close() + + +@pytest.mark.asyncio +async def test_cancelled_application_close_remains_retryable() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + transcriber = CancellingOnceTranscriber(events) + runtime = ApplicationRuntime(manager, transcriber=transcriber) + + with pytest.raises(asyncio.CancelledError): + await runtime.close() + + assert runtime._closed is False + assert runtime._transcriber is transcriber + + await runtime.close() + + assert transcriber.close_calls == 2 + assert runtime._transcriber is None + assert runtime._closed is True + + +@pytest.mark.asyncio +async def test_startup_failure_closes_owned_transcriber() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + transcriber = TrackingTranscriber(events) + runtime = ApplicationRuntime(manager, transcriber=transcriber) + + with ( + patch.object( + manager, + "validate_configured_models", + AsyncMock(side_effect=RuntimeError("startup failed")), + ), + pytest.raises(RuntimeError, match="startup failed"), + ): + await runtime.start() + + assert transcriber.close_calls == 1 + + +@pytest.mark.asyncio +async def test_startup_cancellation_cleans_partial_messaging_and_reraises() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + transcriber = TrackingTranscriber(events) + runtime = ApplicationRuntime(manager, transcriber=transcriber) + messaging = TrackingMessagingRuntime(events) + entered = asyncio.Event() + + async def start_messaging() -> None: + runtime._messaging_runtime = messaging + entered.set() + await asyncio.Event().wait() + + with patch.object( + runtime, + "_start_messaging_if_configured", + side_effect=start_messaging, + ): + start_task = asyncio.create_task(runtime.start()) + await entered.wait() + start_task.cancel() + with pytest.raises(asyncio.CancelledError): + await start_task + + assert events == [ + "messaging.quiesce", + "messaging.close", + "transcriber.close", + ] + assert runtime._closed is True + assert runtime._messaging_runtime is None + assert runtime._transcriber is None + + +@pytest.mark.asyncio +async def test_public_start_retries_transient_partial_messaging_cleanup() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + messaging = TrackingMessagingRuntime(events, fail_quiesce_once=True) + workflow = MagicMock() + workflow.close = AsyncMock(side_effect=lambda: events.append("workflow.close")) + cli_manager = MagicMock() + components = MessagingPlatformComponents( + name="tracking", + runtime=messaging, + outbound=MagicMock(), + ) + startup_failure = RuntimeError("partial messaging startup failed") + + async def fail_after_publication( + published: MessagingPlatformComponents, + ) -> None: + assert published is components + runtime._messaging_runtime = messaging + runtime._messaging_workflow = workflow + runtime._cli_manager = cli_manager + raise startup_failure + + with ( + patch.object( + runtime, + "_validate_configured_models_best_effort", + AsyncMock(), + ), + patch.object(manager, "start_model_list_refresh"), + patch( + "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components", + return_value=components, + ), + patch.object( + runtime, + "_start_messaging_workflow", + side_effect=fail_after_publication, + ), + pytest.raises(RuntimeError, match="cleanup incomplete") as raised, + ): + await runtime.start() + + assert raised.value.__cause__ is startup_failure + assert events == [ + "messaging.quiesce", + "messaging.quiesce", + "workflow.close", + "messaging.close", + ] + workflow.close.assert_awaited_once() + assert runtime._messaging_runtime is None + assert runtime._messaging_workflow is None + assert runtime._cli_manager is None + assert runtime.is_closed is True + + +@pytest.mark.asyncio +async def test_public_start_retains_persistently_unclean_partial_messaging_graph() -> ( + None +): + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + transcriber = TrackingTranscriber(events) + runtime = ApplicationRuntime(manager, transcriber=transcriber) + messaging = PersistentlyFailingMessagingRuntime(events) + workflow = MagicMock() + workflow.close = AsyncMock(side_effect=lambda: events.append("workflow.close")) + cli_manager = MagicMock() + components = MessagingPlatformComponents( + name="tracking", + runtime=messaging, + outbound=MagicMock(), + ) + startup_failure = RuntimeError("partial messaging startup failed") + + async def fail_after_publication( + published: MessagingPlatformComponents, + ) -> None: + assert published is components + runtime._messaging_runtime = messaging + runtime._messaging_workflow = workflow + runtime._cli_manager = cli_manager + raise startup_failure + + with ( + patch.object( + runtime, + "_validate_configured_models_best_effort", + AsyncMock(), + ), + patch.object(manager, "start_model_list_refresh"), + patch( + "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components", + return_value=components, + ), + patch.object( + runtime, + "_start_messaging_workflow", + side_effect=fail_after_publication, + ), + pytest.raises(RuntimeError, match="cleanup incomplete") as raised, + ): + await runtime.start() + + assert raised.value.__cause__ is startup_failure + assert events == ["messaging.quiesce", "messaging.quiesce"] + workflow.close.assert_not_awaited() + assert transcriber.close_calls == 0 + assert runtime._messaging_runtime is messaging + assert runtime._messaging_workflow is workflow + assert runtime._cli_manager is cli_manager + assert runtime._transcriber is transcriber + assert runtime._provider_manager_closed is False + assert runtime.is_closed is False + + messaging.fail_quiesce = False + assert await runtime.close() is True + + assert events == [ + "messaging.quiesce", + "messaging.quiesce", + "messaging.quiesce", + "workflow.close", + "messaging.close", + "transcriber.close", + ] + assert runtime.is_closed is True + + +@pytest.mark.asyncio +async def test_messaging_start_failure_is_nonfatal_after_complete_cleanup() -> None: + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + + with ( + patch( + "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components", + side_effect=RuntimeError("messaging unavailable"), + ), + patch.object(runtime, "_cleanup_messaging", AsyncMock(return_value=True)), + ): + await runtime._start_messaging_if_configured() + + await manager.close() + + +@pytest.mark.asyncio +async def test_messaging_start_failure_fails_closed_when_cleanup_is_incomplete() -> ( + None +): + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + + with ( + patch( + "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components", + side_effect=RuntimeError("messaging unavailable"), + ), + patch.object(runtime, "_cleanup_messaging", AsyncMock(return_value=False)), + pytest.raises(RuntimeError, match="cleanup incomplete") as exc_info, + ): + await runtime._start_messaging_if_configured() + + assert isinstance(exc_info.value.__cause__, RuntimeError) + await manager.close() + + +@pytest.mark.asyncio +async def test_composition_records_runtime_before_workspace_setup() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + messaging = TrackingMessagingRuntime(events) + components = MessagingPlatformComponents( + name="tracking", + runtime=messaging, + outbound=MagicMock(), + ) + + with ( + patch( + "free_claude_code.runtime.application.os.makedirs", + side_effect=OSError("workspace failed"), + ), + pytest.raises(OSError, match="workspace failed"), + ): + await runtime._start_messaging_workflow(components) + + assert runtime._messaging_runtime is messaging + + await runtime.close() + + assert events == ["messaging.quiesce", "messaging.close"] + assert runtime._closed is True + + +@pytest.mark.asyncio +async def test_composition_publishes_startup_notice_after_runtime_and_repair() -> None: + events: list[str] = [] + manager = ProviderRuntimeManager(_settings("nvidia_nim/model")) + runtime = ApplicationRuntime(manager, transcriber=None) + messaging = TrackingMessagingRuntime(events) + notice = MessagingStartupNotice( + chat_id="chat", + transport_label="test transport", + ) + components = MessagingPlatformComponents( + name="tracking", + runtime=messaging, + outbound=MagicMock(), + startup_notice=notice, + ) + workflow = MagicMock() + workflow.handle_message = AsyncMock() + workflow.restore.side_effect = lambda: events.append("workflow.restore") + workflow.repair_restored_statuses = AsyncMock( + side_effect=lambda: events.append("workflow.repair") + ) + workflow.publish_startup_notice = AsyncMock( + side_effect=lambda published: events.append("workflow.notice") + ) + workflow.close = AsyncMock() + cli_manager = MagicMock() + + with ( + patch( + "free_claude_code.runtime.application.cli_managed.ManagedClaudeSessionManager", + return_value=cli_manager, + ) as manager_constructor, + patch("free_claude_code.runtime.application.messaging_session.SessionStore"), + patch( + "free_claude_code.runtime.application.messaging_workflow_module.MessagingWorkflow", + return_value=workflow, + ), + ): + await runtime._start_messaging_workflow(components) + + assert events == [ + "workflow.restore", + "messaging.start", + "workflow.repair", + "workflow.notice", + ] + workflow.publish_startup_notice.assert_awaited_once_with(notice) + assert manager_constructor.call_args.kwargs["proxy_root_url"] == ( + "http://127.0.0.1:8082" + ) + assert "api_url" not in manager_constructor.call_args.kwargs + assert "plans_directory" not in manager_constructor.call_args.kwargs + + assert await runtime.close() is True diff --git a/tests/runtime/test_provider_manager.py b/tests/runtime/test_provider_manager.py new file mode 100644 index 0000000..77d59ae --- /dev/null +++ b/tests/runtime/test_provider_manager.py @@ -0,0 +1,635 @@ +import asyncio +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from free_claude_code.application.errors import ApplicationUnavailableError +from free_claude_code.application.model_metadata import ProviderModelInfo +from free_claude_code.config.settings import Settings +from free_claude_code.providers.base import BaseProvider +from free_claude_code.providers.nvidia_nim import NvidiaNimProvider +from free_claude_code.providers.runtime import ProviderRuntime +from free_claude_code.runtime.provider_manager import ProviderRuntimeManager + + +class FakeRuntime(ProviderRuntime): + def __init__(self, settings: Settings) -> None: + self.settings = settings + self.cleanup_calls = 0 + self.cleanup_error: Exception | None = None + self.cleanup_started: asyncio.Event | None = None + self.cleanup_release: asyncio.Event | None = None + self.provider = MagicMock() + self.provider.list_model_infos = AsyncMock(return_value=frozenset()) + + def is_cached(self, provider_id: str) -> bool: + return provider_id == "cached" + + def resolve_provider(self, provider_id: str) -> BaseProvider: + return cast(BaseProvider, self.provider) + + async def cleanup(self) -> None: + self.cleanup_calls += 1 + if self.cleanup_started is not None: + self.cleanup_started.set() + if self.cleanup_release is not None: + await self.cleanup_release.wait() + if self.cleanup_error is not None: + raise self.cleanup_error + + +class RuntimeFactory: + def __init__(self) -> None: + self.runtimes: list[FakeRuntime] = [] + self.error: Exception | None = None + + def __call__(self, settings: Settings) -> ProviderRuntime: + if self.error is not None: + raise self.error + runtime = FakeRuntime(settings) + self.runtimes.append(runtime) + return runtime + + +def _settings(model: str) -> Settings: + return Settings().model_copy(update={"model": model}) + + +@pytest.mark.asyncio +async def test_startup_generation_lease_and_shutdown_close_exactly_once() -> None: + factory = RuntimeFactory() + settings = _settings("nvidia_nim/one") + manager = ProviderRuntimeManager(settings, runtime_factory=factory) + + lease = await manager.acquire() + + assert lease.generation_id == 1 + assert lease.settings is settings + assert lease.is_provider_cached("cached") is True + assert lease.resolve_provider("nvidia_nim") is factory.runtimes[0].provider + await lease.release() + await lease.release() + await manager.close() + await manager.close() + + assert factory.runtimes[0].cleanup_calls == 1 + with pytest.raises(ApplicationUnavailableError, match="shutting down"): + await manager.acquire() + + +@pytest.mark.asyncio +async def test_replacement_keeps_leased_generation_open_until_final_release() -> None: + factory = RuntimeFactory() + first_settings = _settings("nvidia_nim/one") + second_settings = _settings("nvidia_nim/two") + manager = ProviderRuntimeManager(first_settings, runtime_factory=factory) + old_lease = await manager.acquire() + committed: list[str] = [] + + generation_id = await manager.replace( + second_settings, + commit=lambda: committed.append("persisted"), + ) + new_lease = await manager.acquire() + + assert generation_id == 2 + assert committed == ["persisted"] + assert new_lease.generation_id == 2 + assert new_lease.settings is second_settings + assert factory.runtimes[0].cleanup_calls == 0 + await new_lease.release() + await old_lease.release() + assert factory.runtimes[0].cleanup_calls == 1 + await manager.close() + assert factory.runtimes[1].cleanup_calls == 1 + + +@pytest.mark.asyncio +async def test_real_hot_replacement_owns_a_limiter_per_provider_generation() -> None: + first_settings = _settings("nvidia_nim/one") + second_settings = _settings("nvidia_nim/two") + clients: list[MagicMock] = [] + + def create_client(*_args: object, **_kwargs: object) -> MagicMock: + client = MagicMock() + client.close = AsyncMock() + clients.append(client) + return client + + with patch( + "free_claude_code.providers.openai_chat.provider.AsyncOpenAI", + side_effect=create_client, + ): + manager = ProviderRuntimeManager(first_settings) + old_lease = await manager.acquire() + old_provider = old_lease.resolve_provider("nvidia_nim") + refresh = AsyncMock() + + with patch.object(manager, "_refresh_generation", refresh): + await manager.replace(second_settings, commit=lambda: None) + new_lease = await manager.acquire() + new_provider = new_lease.resolve_provider("nvidia_nim") + await asyncio.sleep(0) + + assert isinstance(old_provider, NvidiaNimProvider) + assert isinstance(new_provider, NvidiaNimProvider) + assert new_provider is not old_provider + assert new_provider._rate_limiter is not old_provider._rate_limiter + assert old_lease.resolve_provider("nvidia_nim") is old_provider + clients[0].close.assert_not_awaited() + + await new_lease.release() + await old_lease.release() + + clients[0].close.assert_awaited_once() + clients[1].close.assert_not_awaited() + refresh.assert_awaited_once() + await manager.close() + + clients[1].close.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_replacement_closes_unleased_generation_immediately() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + + await manager.replace( + _settings("nvidia_nim/two"), + commit=lambda: None, + ) + + assert factory.runtimes[0].cleanup_calls == 1 + await manager.close() + + +@pytest.mark.asyncio +async def test_cancelled_replacement_does_not_cancel_owned_generation_cleanup() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + cleanup_started = asyncio.Event() + cleanup_release = asyncio.Event() + refresh_started = asyncio.Event() + factory.runtimes[0].cleanup_started = cleanup_started + factory.runtimes[0].cleanup_release = cleanup_release + + async def refresh(*_args: object, **_kwargs: object) -> None: + refresh_started.set() + await asyncio.Event().wait() + + with patch.object(manager, "_refresh_generation", side_effect=refresh): + replace_task = asyncio.create_task( + manager.replace( + _settings("nvidia_nim/two"), + commit=lambda: None, + ) + ) + await cleanup_started.wait() + await refresh_started.wait() + + replace_task.cancel() + with pytest.raises(asyncio.CancelledError): + await replace_task + + retired = manager._retired[1] + assert manager.current_generation_id == 2 + assert retired.cleanup_task is not None + assert not retired.cleanup_task.cancelled() + assert factory.runtimes[0].cleanup_calls == 1 + + close_task = asyncio.create_task(manager.close()) + await asyncio.sleep(0) + assert not close_task.done() + cleanup_release.set() + await close_task + + assert factory.runtimes[0].cleanup_calls == 1 + assert factory.runtimes[1].cleanup_calls == 1 + assert manager._retired == {} + + +@pytest.mark.asyncio +async def test_cancelled_final_lease_release_keeps_owned_cleanup_running() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + lease = await manager.acquire() + await manager.replace( + _settings("nvidia_nim/two"), + commit=lambda: None, + ) + cleanup_started = asyncio.Event() + cleanup_release = asyncio.Event() + factory.runtimes[0].cleanup_started = cleanup_started + factory.runtimes[0].cleanup_release = cleanup_release + + release_task = asyncio.create_task(lease.release()) + await cleanup_started.wait() + release_task.cancel() + + with pytest.raises(asyncio.CancelledError): + await release_task + + retired = manager._retired[1] + assert retired.active_leases == 0 + assert retired.cleanup_task is not None + assert not retired.cleanup_task.cancelled() + assert factory.runtimes[0].cleanup_calls == 1 + + close_task = asyncio.create_task(manager.close()) + await asyncio.sleep(0) + assert not close_task.done() + cleanup_release.set() + await close_task + + assert factory.runtimes[0].cleanup_calls == 1 + assert manager._retired == {} + + +@pytest.mark.asyncio +async def test_hot_cleanup_failure_keeps_published_replacement() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + factory.runtimes[0].cleanup_error = RuntimeError("cleanup failed") + + generation_id = await manager.replace( + _settings("nvidia_nim/two"), + commit=lambda: None, + ) + + assert generation_id == 2 + assert manager.current_generation_id == 2 + assert factory.runtimes[0].cleanup_calls == 1 + assert 1 in manager._retired + + factory.runtimes[0].cleanup_error = None + await manager.close() + + assert factory.runtimes[0].cleanup_calls == 2 + assert manager._retired == {} + + +@pytest.mark.asyncio +async def test_candidate_construction_failure_preserves_current_generation() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + factory.error = RuntimeError("cannot construct") + + with pytest.raises(RuntimeError, match="cannot construct"): + await manager.replace( + _settings("nvidia_nim/two"), + commit=lambda: None, + ) + + assert manager.current_generation_id == 1 + assert manager.current_settings().model == "nvidia_nim/one" + assert factory.runtimes[0].cleanup_calls == 0 + await manager.close() + + +@pytest.mark.asyncio +async def test_failed_candidate_cleanup_is_retried_at_shutdown() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + + def fail_commit() -> None: + factory.runtimes[1].cleanup_error = RuntimeError("private cleanup detail") + raise OSError("disk full") + + with pytest.raises(OSError, match="disk full"): + await manager.replace( + _settings("nvidia_nim/two"), + commit=fail_commit, + ) + + assert manager.current_generation_id == 1 + assert factory.runtimes[0].cleanup_calls == 0 + assert factory.runtimes[1].cleanup_calls == 1 + assert manager._unpublished == {factory.runtimes[1]} + + manager.cache_model_infos("nvidia_nim", {ProviderModelInfo("cached")}) + with pytest.raises( + RuntimeError, + match="One or more provider runtimes failed to close", + ) as exc_info: + await manager.close() + + assert "private cleanup detail" not in str(exc_info.value) + assert manager._closed is False + assert manager._unpublished == {factory.runtimes[1]} + assert manager.cached_model_ids() == {"nvidia_nim": frozenset({"cached"})} + + factory.runtimes[1].cleanup_error = None + await manager.close() + + assert factory.runtimes[0].cleanup_calls == 1 + assert factory.runtimes[1].cleanup_calls == 3 + assert manager._unpublished == set() + assert manager.cached_model_ids() == {} + assert manager._closed is True + + +@pytest.mark.asyncio +async def test_later_replacement_retries_failed_unpublished_candidate() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + + def fail_commit() -> None: + factory.runtimes[1].cleanup_error = RuntimeError("cleanup failed") + raise OSError("disk full") + + with pytest.raises(OSError, match="disk full"): + await manager.replace( + _settings("nvidia_nim/two"), + commit=fail_commit, + ) + + factory.runtimes[1].cleanup_error = None + generation_id = await manager.replace( + _settings("nvidia_nim/three"), + commit=lambda: None, + ) + + assert generation_id == 2 + assert factory.runtimes[1].cleanup_calls == 2 + assert manager._unpublished == set() + await manager.close() + + +@pytest.mark.asyncio +async def test_cancelled_candidate_cleanup_remains_owned_until_shutdown() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + cleanup_started = asyncio.Event() + cleanup_release = asyncio.Event() + + def fail_commit() -> None: + candidate = factory.runtimes[1] + candidate.cleanup_started = cleanup_started + candidate.cleanup_release = cleanup_release + raise OSError("disk full") + + replace_task = asyncio.create_task( + manager.replace( + _settings("nvidia_nim/two"), + commit=fail_commit, + ) + ) + await cleanup_started.wait() + replace_task.cancel() + + with pytest.raises(asyncio.CancelledError): + await replace_task + + assert manager._unpublished == {factory.runtimes[1]} + assert factory.runtimes[1].cleanup_calls == 1 + + cleanup_release.set() + await manager.close() + + assert factory.runtimes[1].cleanup_calls == 2 + assert manager._unpublished == set() + + +@pytest.mark.asyncio +async def test_concurrent_replacements_are_serialized_in_call_order() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + first_entered = asyncio.Event() + release_first = asyncio.Event() + cancel_calls = 0 + original_cancel = manager._cancel_refresh + + async def controlled_cancel() -> None: + nonlocal cancel_calls + cancel_calls += 1 + if cancel_calls == 1: + first_entered.set() + await release_first.wait() + await original_cancel() + + with patch.object(manager, "_cancel_refresh", side_effect=controlled_cancel): + first = asyncio.create_task( + manager.replace( + _settings("nvidia_nim/two"), + commit=lambda: None, + ) + ) + await first_entered.wait() + second = asyncio.create_task( + manager.replace( + _settings("nvidia_nim/three"), + commit=lambda: None, + ) + ) + await asyncio.sleep(0) + assert len(factory.runtimes) == 1 + assert not second.done() + release_first.set() + assert await asyncio.gather(first, second) == [2, 3] + + assert manager.current_settings().model == "nvidia_nim/three" + assert [runtime.cleanup_calls for runtime in factory.runtimes[:2]] == [1, 1] + await manager.close() + + +@pytest.mark.asyncio +async def test_shutdown_waits_for_active_lease_then_rejects_new_work() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + lease = await manager.acquire() + + close_task = asyncio.create_task(manager.close()) + await asyncio.sleep(0) + + assert not close_task.done() + with pytest.raises(ApplicationUnavailableError, match="shutting down"): + await manager.acquire() + await lease.release() + await close_task + assert factory.runtimes[0].cleanup_calls == 1 + + +@pytest.mark.asyncio +async def test_cancelled_shutdown_retains_generation_for_retry() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + lease = await manager.acquire() + close_task = asyncio.create_task(manager.close()) + await asyncio.sleep(0) + + close_task.cancel() + with pytest.raises(asyncio.CancelledError): + await close_task + + with pytest.raises(ApplicationUnavailableError, match="shutting down"): + await manager.acquire() + with pytest.raises(ApplicationUnavailableError, match="shutting down"): + await manager.replace(_settings("nvidia_nim/two"), commit=lambda: None) + assert factory.runtimes[0].cleanup_calls == 0 + + await lease.release() + await manager.close() + + assert factory.runtimes[0].cleanup_calls == 1 + assert manager._closed is True + + +@pytest.mark.asyncio +async def test_cancelled_shutdown_reuses_the_same_owned_cleanup_task() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + cleanup_started = asyncio.Event() + cleanup_release = asyncio.Event() + cleanup_calls = 0 + + async def cleanup() -> None: + nonlocal cleanup_calls + cleanup_calls += 1 + cleanup_started.set() + await cleanup_release.wait() + + with patch.object(factory.runtimes[0], "cleanup", side_effect=cleanup): + close_task = asyncio.create_task(manager.close()) + await cleanup_started.wait() + + close_task.cancel() + with pytest.raises(asyncio.CancelledError): + await close_task + + assert manager._closed is False + assert manager._retired + + retry_task = asyncio.create_task(manager.close()) + await asyncio.sleep(0) + assert not retry_task.done() + cleanup_release.set() + await retry_task + + assert cleanup_calls == 1 + assert manager._retired == {} + assert manager._closed is True + + +@pytest.mark.asyncio +async def test_failed_shutdown_cleanup_is_retryable() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + manager.cache_model_infos("nvidia_nim", {ProviderModelInfo("cached")}) + factory.runtimes[0].cleanup_error = RuntimeError("private provider detail") + + with pytest.raises( + RuntimeError, + match="One or more provider runtimes failed to close", + ) as exc_info: + await manager.close() + + assert "private provider detail" not in str(exc_info.value) + assert manager._closed is False + assert 1 in manager._retired + assert manager.cached_model_ids() == {"nvidia_nim": frozenset({"cached"})} + assert factory.runtimes[0].cleanup_calls == 1 + + factory.runtimes[0].cleanup_error = None + await manager.close() + + assert factory.runtimes[0].cleanup_calls == 2 + assert manager._retired == {} + assert manager.cached_model_ids() == {} + assert manager._closed is True + + +@pytest.mark.asyncio +async def test_application_catalog_survives_generation_replacement() -> None: + factory = RuntimeFactory() + manager = ProviderRuntimeManager( + _settings("open_router/one"), + runtime_factory=factory, + ) + manager.cache_model_infos( + "open_router", + {ProviderModelInfo("persisted", supports_thinking=True)}, + ) + + await manager.replace( + _settings("open_router/two"), + commit=lambda: None, + ) + + assert manager.cached_model_ids() == {"open_router": frozenset({"persisted"})} + assert manager.cached_model_supports_thinking("open_router", "persisted") is True + await manager.close() + + +@pytest.mark.asyncio +async def test_generation_lifecycle_traces_contain_minimal_correlation_fields() -> None: + factory = RuntimeFactory() + + with patch("free_claude_code.runtime.provider_manager.trace_event") as trace: + manager = ProviderRuntimeManager( + _settings("nvidia_nim/one"), + runtime_factory=factory, + ) + lease = await manager.acquire() + await manager.replace( + _settings("nvidia_nim/two"), + commit=lambda: None, + reason="test_replace", + ) + await lease.release() + await manager.close() + + events = [call.kwargs for call in trace.call_args_list] + names = [event["event"] for event in events] + assert names == [ + "provider_generation.published", + "provider_generation.published", + "provider_generation.retired", + "provider_generation.closed", + "provider_generation.retired", + "provider_generation.closed", + ] + assert events[1]["generation_id"] == 2 + assert events[1]["previous_generation_id"] == 1 + assert events[1]["reason"] == "test_replace" + assert events[2]["active_leases"] == 1 + assert events[3]["forced"] is False diff --git a/tests/scripts/test_ci_scripts.py b/tests/scripts/test_ci_scripts.py new file mode 100644 index 0000000..7f1f0d7 --- /dev/null +++ b/tests/scripts/test_ci_scripts.py @@ -0,0 +1,307 @@ +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _script_text(name: str) -> str: + return (_repo_root() / "scripts" / name).read_text(encoding="utf-8") + + +def _braced_body(text: str, declaration: str) -> str: + start = text.index(declaration) + brace_start = text.index("{", start) + depth = 0 + + for index, char in enumerate(text[brace_start:], start=brace_start): + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return text[brace_start + 1 : index] + + raise AssertionError(f"Unclosed function body for {declaration}") + + +def _path_without_uv() -> str: + uv_names = ("uv", "uv.exe", "uv.cmd", "uv.bat") + entries = [] + for raw_entry in os.environ.get("PATH", "").split(os.pathsep): + if not raw_entry: + continue + entry = Path(raw_entry) + if any((entry / name).exists() for name in uv_names): + continue + entries.append(raw_entry) + return os.pathsep.join(entries) + + +def _shell_interpreter() -> str: + sh = shutil.which("sh") + if sh is None: + pytest.skip("sh is not available on this platform") + return sh + + +def _powershell_interpreter() -> str: + pwsh = shutil.which("pwsh") or shutil.which("powershell") + if pwsh is None: + pytest.skip("PowerShell is not available on this platform") + return pwsh + + +def test_ci_sh_runs_ci_checks_in_order() -> None: + text = _script_text("ci.sh") + legacy_future_import = "from __future__ import " + "annotations" + + assert 'CHECK_ORDER="suppressions ruff-format ruff-check ty pytest"' in text + assert "grep -rE" in text + assert "Fix the underlying type/import issue instead" in text + assert legacy_future_import in text + assert "legacy future annotations are not allowed" in text + assert "--exclude-dir=.venv" in text + assert "--exclude-dir=.git" in text + assert "uv run ruff format" in text + assert "uv run ruff format --check" not in text + assert "uv run ruff check --fix" in text + assert "uv run ty check" in text + assert "uv run pytest -v --tb=short" in text + assert "--only" in text + assert "--skip" in text + assert "--dry-run" in text + assert "uv is required but was not found on PATH" in text + assert "npm" not in text + assert "smoke/" not in text + assert "uv self update" not in text + + +def test_ci_sh_dry_run_does_not_require_uv() -> None: + result = subprocess.run( + [ + _shell_interpreter(), + str(_repo_root() / "scripts" / "ci.sh"), + "--only", + "pytest", + "--dry-run", + ], + cwd=_repo_root(), + env={**os.environ, "PATH": _path_without_uv()}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert "+ uv run pytest -v --tb=short" in result.stdout + assert "uv is required" not in result.stderr + + +@pytest.mark.parametrize( + ("check_id", "command"), + [ + ("ruff-format", "+ uv run ruff format"), + ("ruff-check", "+ uv run ruff check --fix"), + ], +) +def test_ci_sh_dry_run_prints_local_ruff_repair_commands( + check_id: str, command: str +) -> None: + result = subprocess.run( + [ + _shell_interpreter(), + str(_repo_root() / "scripts" / "ci.sh"), + "--only", + check_id, + "--dry-run", + ], + cwd=_repo_root(), + env={**os.environ, "PATH": _path_without_uv()}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert command in result.stdout + assert "uv is required" not in result.stderr + + +def test_ci_sh_suppression_only_does_not_require_uv() -> None: + result = subprocess.run( + [ + _shell_interpreter(), + str(_repo_root() / "scripts" / "ci.sh"), + "--only", + "suppressions", + ], + cwd=_repo_root(), + env={**os.environ, "PATH": _path_without_uv()}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert "Ban suppressions and legacy annotations" in result.stdout + assert "uv is required" not in result.stderr + + +def test_ci_sh_is_tracked_executable() -> None: + result = subprocess.run( + ["git", "ls-files", "--stage", "scripts/ci.sh"], + cwd=_repo_root(), + text=True, + capture_output=True, + check=True, + ) + + assert result.stdout.startswith("100755 ") + + +def test_ci_sh_fail_fast_runs_checks_sequentially() -> None: + text = _script_text("ci.sh") + main = text[text.index('parse_args "$@"') :] + + suppress_index = text.index("run_suppressions()") + ruff_format_index = text.index("run_ruff_format()") + ruff_check_index = text.index("run_ruff_check()") + ty_index = text.index("run_ty()") + pytest_index = text.index("run_pytest()") + + assert ( + suppress_index < ruff_format_index < ruff_check_index < ty_index < pytest_index + ) + assert "for check_id in $CHECK_ORDER" in main + + +def test_ci_ps1_runs_ci_checks_in_order() -> None: + text = _script_text("ci.ps1") + legacy_future_import = "from __future__ import " + "annotations" + + assert '"suppressions"' in text + assert '"ruff-format"' in text + assert '"ruff-check"' in text + assert '"ty"' in text + assert '"pytest"' in text + assert "Select-String -Pattern" in text + assert "Fix the underlying type/import issue instead" in text + assert legacy_future_import in text + assert "legacy future annotations are not allowed" in text + assert ".venv" in text + assert ".git" in text + assert '"run", "ruff", "format"' in text + assert '"format", "--check"' not in text + assert '"run", "ruff", "check", "--fix"' in text + assert '"-v", "--tb=short"' in text + assert "-Only" in text + assert "-Skip" in text + assert "-DryRun" in text + assert "uv is required but was not found on PATH" in text + assert "npm" not in text + assert "smoke/" not in text + assert "uv self update" not in text + + +def test_ci_ps1_dry_run_does_not_require_uv() -> None: + result = subprocess.run( + [ + _powershell_interpreter(), + "-NoProfile", + "-File", + str(_repo_root() / "scripts" / "ci.ps1"), + "-Only", + "pytest", + "-DryRun", + ], + cwd=_repo_root(), + env={**os.environ, "PATH": _path_without_uv()}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert "+ uv run pytest -v --tb=short" in result.stdout + assert "uv is required" not in result.stderr + + +@pytest.mark.parametrize( + ("check_id", "command"), + [ + ("ruff-format", "+ uv run ruff format"), + ("ruff-check", "+ uv run ruff check --fix"), + ], +) +def test_ci_ps1_dry_run_prints_local_ruff_repair_commands( + check_id: str, command: str +) -> None: + result = subprocess.run( + [ + _powershell_interpreter(), + "-NoProfile", + "-File", + str(_repo_root() / "scripts" / "ci.ps1"), + "-Only", + check_id, + "-DryRun", + ], + cwd=_repo_root(), + env={**os.environ, "PATH": _path_without_uv()}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert command in result.stdout + assert "uv is required" not in result.stderr + + +def test_ci_ps1_suppression_only_does_not_require_uv() -> None: + result = subprocess.run( + [ + _powershell_interpreter(), + "-NoProfile", + "-File", + str(_repo_root() / "scripts" / "ci.ps1"), + "-Only", + "suppressions", + ], + cwd=_repo_root(), + env={**os.environ, "PATH": _path_without_uv()}, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0 + assert "Ban suppressions and legacy annotations" in result.stdout + assert "uv is required" not in result.stderr + + +def test_ci_ps1_fail_fast_runs_checks_sequentially() -> None: + text = _script_text("ci.ps1") + + assert "foreach ($checkId in $CheckOrder)" in text + assert "Invoke-SuppressionsCheck" in text + assert "Invoke-RuffFormatCheck" in text + assert "Invoke-RuffLintCheck" in text + assert "Invoke-TyCheck" in text + assert "Invoke-PytestCheck" in text + + suppress_index = text.index("function Invoke-SuppressionsCheck") + ruff_format_index = text.index("function Invoke-RuffFormatCheck") + ruff_check_index = text.index("function Invoke-RuffLintCheck") + ty_index = text.index("function Invoke-TyCheck") + pytest_index = text.index("function Invoke-PytestCheck") + + assert ( + suppress_index < ruff_format_index < ruff_check_index < ty_index < pytest_index + ) diff --git a/tests/scripts/test_installers.py b/tests/scripts/test_installers.py new file mode 100644 index 0000000..b4030a9 --- /dev/null +++ b/tests/scripts/test_installers.py @@ -0,0 +1,986 @@ +import os +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +import pytest + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _write_executable(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + path.chmod(0o755) + + +def _braced_body(text: str, declaration: str) -> str: + start = text.index(declaration) + brace_start = text.index("{", start) + depth = 0 + for index, char in enumerate(text[brace_start:], start=brace_start): + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return text[brace_start + 1 : index] + raise AssertionError(f"Unclosed function body for {declaration}") + + +def _posix_command(name: str) -> str: + help_output = ( + ' echo " --extension, -e Load an extension"\n' + ' echo " --models Scope models"' + if name == "pi" + else " :" + ) + return f"""#!/bin/sh +echo "{name}:$*" >> "$CALL_LOG" +if [ "$FAIL_STEP" = "{name}-verify" ]; then + exit 31 +fi +if [ "${{1:-}}" = "--version" ]; then + echo "{name} 1.0.0" +fi +if [ "${{1:-}}" = "--help" ]; then +{help_output} +fi +""" + + +def _posix_npm_command() -> str: + return """#!/bin/sh +echo "npm:$*" >> "$CALL_LOG" +if [ "${1:-}" = "prefix" ] && [ "${2:-}" = "-g" ]; then + printf '%s\n' "$FAKE_NPM_PREFIX" + exit 0 +fi +if [ "${1:-}" = "config" ] && [ "${2:-}" = "get" ] && [ "${3:-}" = "prefix" ]; then + printf '%s\n' "$FAKE_NPM_PREFIX" + exit 0 +fi +exit 71 +""" + + +def _posix_uv_command(version: str) -> str: + return f"""#!/bin/sh +echo "uv:$*" >> "$CALL_LOG" +if [ "${{1:-}}" = "--version" ]; then + if [ "$FAIL_STEP" = "uv-verify" ]; then + exit 32 + fi + echo "uv {version}" + exit 0 +fi +if [ "${{1:-}}" = "tool" ] && [ "${{2:-}}" = "install" ]; then + if [ "$FAIL_STEP" = "fcc-install" ]; then + exit 33 + fi + mkdir -p "$FAKE_TOOL_BIN" + cp "$FAKE_FIXTURES/fcc-command.sh" "$FAKE_TOOL_BIN/fcc-server" + cp "$FAKE_FIXTURES/fcc-command.sh" "$FAKE_TOOL_BIN/fcc-claude" + cp "$FAKE_FIXTURES/fcc-command.sh" "$FAKE_TOOL_BIN/fcc-pi" + if [ "$FAIL_STEP" != "fcc-missing" ]; then + cp "$FAKE_FIXTURES/fcc-command.sh" "$FAKE_TOOL_BIN/fcc-codex" + fi + chmod +x "$FAKE_TOOL_BIN"/fcc-* + exit 0 +fi +if [ "${{1:-}}" = "tool" ] && [ "${{2:-}}" = "update-shell" ]; then + if [ "$FAIL_STEP" = "path-update" ]; then + exit 34 + fi + exit 0 +fi +if [ "${{1:-}}" = "tool" ] && [ "${{2:-}}" = "dir" ] && [ "${{3:-}}" = "--bin" ]; then + printf '%s\n' "$FAKE_TOOL_BIN" + exit 0 +fi +exit 35 +""" + + +@dataclass +class PosixHarness: + root: Path + bin_dir: Path + fixtures: Path + tool_bin: Path + log: Path + env: dict[str, str] + + def add_client(self, name: str) -> None: + _write_executable(self.bin_dir / name, _posix_command(name)) + + def add_unrelated_pi(self) -> None: + _write_executable(self.bin_dir / "pi", _posix_command("unrelated-pi")) + + def add_npm_prefix(self, prefix: Path) -> None: + prefix.mkdir(parents=True) + self.env["FAKE_NPM_PREFIX"] = str(prefix) + _write_executable(self.bin_dir / "npm", _posix_npm_command()) + + def add_uv(self, version: str) -> None: + _write_executable(self.bin_dir / "uv", _posix_uv_command(version)) + + def run(self, *args: str, fail_step: str = "") -> subprocess.CompletedProcess[str]: + env = self.env | {"FAIL_STEP": fail_step} + return subprocess.run( + ["/bin/sh", str(_repo_root() / "scripts" / "install.sh"), *args], + check=False, + capture_output=True, + text=True, + env=env, + ) + + def calls(self) -> list[str]: + if not self.log.exists(): + return [] + return self.log.read_text(encoding="utf-8").splitlines() + + +@pytest.fixture +def posix_harness(tmp_path: Path) -> PosixHarness: + if os.name == "nt": + pytest.skip("POSIX installer scenarios run on POSIX hosts") + + bin_dir = tmp_path / "bin" + fixtures = tmp_path / "fixtures" + tool_bin = tmp_path / "tool-bin" + home = tmp_path / "home" + log = tmp_path / "calls.log" + for path in (bin_dir, fixtures, tool_bin, home): + path.mkdir(parents=True) + + _write_executable( + bin_dir / "curl", + """#!/bin/sh +url="" +output="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) + shift + output=$1 + ;; + http*) + url=$1 + ;; + esac + shift +done +echo "download:$url" >> "$CALL_LOG" +case "$url:$FAIL_STEP" in + *claude.ai*:claude-download|*chatgpt.com*:codex-download|*pi.dev*:pi-download|*astral.sh*:uv-download) + exit 41 + ;; +esac +case "$url" in + *claude.ai*) source="$FAKE_FIXTURES/claude-installer.sh" ;; + *chatgpt.com*) source="$FAKE_FIXTURES/codex-installer.sh" ;; + *pi.dev*) source="$FAKE_FIXTURES/pi-installer.sh" ;; + *astral.sh*) source="$FAKE_FIXTURES/uv-installer.sh" ;; + *) exit 42 ;; +esac +cp "$source" "$output" +""", + ) + _write_executable( + fixtures / "claude-installer.sh", + """#!/bin/sh +echo "claude-install" >> "$CALL_LOG" +[ "$FAIL_STEP" = "claude-install" ] && exit 21 +mkdir -p "$HOME/.local/bin" +cp "$FAKE_FIXTURES/claude-command.sh" "$HOME/.local/bin/claude" +chmod +x "$HOME/.local/bin/claude" +""", + ) + _write_executable( + fixtures / "codex-installer.sh", + """#!/bin/sh +echo "codex-install:$CODEX_NON_INTERACTIVE" >> "$CALL_LOG" +[ "$FAIL_STEP" = "codex-install" ] && exit 22 +mkdir -p "$HOME/.local/bin" +cp "$FAKE_FIXTURES/codex-command.sh" "$HOME/.local/bin/codex" +chmod +x "$HOME/.local/bin/codex" +""", + ) + _write_executable( + fixtures / "pi-installer.sh", + """#!/bin/sh +echo "pi-install" >> "$CALL_LOG" +[ "$FAIL_STEP" = "pi-install" ] && exit 24 +if [ -n "${FAKE_NPM_PREFIX:-}" ]; then + pi_bin="$FAKE_NPM_PREFIX/bin" +else + pi_bin="$HOME/.local/bin" +fi +mkdir -p "$pi_bin" +cp "$FAKE_FIXTURES/pi-command.sh" "$pi_bin/pi" +chmod +x "$pi_bin/pi" +""", + ) + _write_executable( + fixtures / "uv-installer.sh", + """#!/bin/sh +echo "uv-install" >> "$CALL_LOG" +[ "$FAIL_STEP" = "uv-install" ] && exit 23 +mkdir -p "$HOME/.local/bin" +cp "$FAKE_FIXTURES/uv-command.sh" "$HOME/.local/bin/uv" +chmod +x "$HOME/.local/bin/uv" +""", + ) + _write_executable(fixtures / "claude-command.sh", _posix_command("claude")) + _write_executable(fixtures / "codex-command.sh", _posix_command("codex")) + _write_executable(fixtures / "pi-command.sh", _posix_command("pi")) + _write_executable(fixtures / "uv-command.sh", _posix_uv_command("0.11.28")) + _write_executable( + fixtures / "fcc-command.sh", + """#!/bin/sh +name=${0##*/} +echo "$name:$*" >> "$CALL_LOG" +if [ "$FAIL_STEP" = "fcc-verify" ]; then + exit 36 +fi +if [ "$name" = "fcc-server" ] && [ "${1:-}" = "--version" ]; then + echo "free-claude-code 3.5.18" +fi +""", + ) + + env = os.environ.copy() + env.update( + { + "PATH": f"{bin_dir}:/usr/bin:/bin", + "HOME": str(home), + "CALL_LOG": str(log), + "FAKE_FIXTURES": str(fixtures), + "FAKE_TOOL_BIN": str(tool_bin), + "FAIL_STEP": "", + } + ) + env.pop("XDG_BIN_HOME", None) + return PosixHarness(tmp_path, bin_dir, fixtures, tool_bin, log, env) + + +def test_install_sh_fresh_install_is_verified(posix_harness: PosixHarness) -> None: + result = posix_harness.run() + + assert result.returncode == 0, result.stderr + assert "Free Claude Code is installed and verified." in result.stdout + calls = posix_harness.calls() + assert calls.index("claude-install") < calls.index("claude:--version") + assert calls.index("codex-install:1") < calls.index("codex:--version") + assert calls.index("pi-install") < calls.index("pi:--version") + assert calls.index("uv-install") < calls.index("uv:--version") + assert any( + call.startswith( + "uv:tool install --force --refresh-package free-claude-code " + "--python 3.14.0 free-claude-code @ " + "https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" + ) + for call in calls + ) + assert not any(call.startswith("git:") for call in calls) + assert calls[-3:] == [ + "uv:tool update-shell", + "uv:tool dir --bin", + "fcc-server:--version", + ] + + +def test_install_sh_preserves_valid_existing_tools( + posix_harness: PosixHarness, +) -> None: + posix_harness.add_client("claude") + posix_harness.add_client("codex") + posix_harness.add_client("pi") + posix_harness.add_uv("0.11.7") + + result = posix_harness.run() + + assert result.returncode == 0, result.stderr + assert not any(call.startswith("download:") for call in posix_harness.calls()) + assert "leaving it unchanged" in result.stdout + + +def test_install_sh_replaces_unrelated_pi_command( + posix_harness: PosixHarness, +) -> None: + posix_harness.add_client("claude") + posix_harness.add_client("codex") + posix_harness.add_unrelated_pi() + posix_harness.add_uv("0.11.7") + + result = posix_harness.run() + + assert result.returncode == 0, result.stderr + assert "is not Pi Coding Agent; installing Pi" in result.stdout + assert "pi-install" in posix_harness.calls() + + +def test_install_sh_discovers_custom_pi_npm_prefix( + posix_harness: PosixHarness, +) -> None: + posix_harness.add_client("claude") + posix_harness.add_client("codex") + posix_harness.add_npm_prefix(posix_harness.root / "custom-npm") + posix_harness.add_uv("0.11.7") + + result = posix_harness.run() + + assert result.returncode == 0, result.stderr + calls = posix_harness.calls() + assert "npm:prefix -g" in calls + assert "pi:--help" in calls + assert "pi:--version" in calls + + +def test_install_sh_replaces_obsolete_uv(posix_harness: PosixHarness) -> None: + posix_harness.add_client("claude") + posix_harness.add_client("codex") + posix_harness.add_client("pi") + posix_harness.add_uv("0.5.9") + + result = posix_harness.run() + + assert result.returncode == 0, result.stderr + assert "uv 0.5.9 is below 0.11.0" in result.stdout + assert "uv-install" in posix_harness.calls() + + +@pytest.mark.parametrize( + "failure", + [ + "claude-download", + "claude-install", + "claude-verify", + "codex-download", + "codex-install", + "codex-verify", + "pi-download", + "pi-install", + "pi-verify", + "uv-download", + "uv-install", + "uv-verify", + "fcc-install", + "path-update", + "fcc-missing", + "fcc-verify", + ], +) +def test_install_sh_stops_without_success_on_each_failure( + posix_harness: PosixHarness, + failure: str, +) -> None: + result = posix_harness.run(fail_step=failure) + + assert result.returncode != 0 + assert "Free Claude Code is installed and verified." not in result.stdout + forbidden = { + "claude-download": "claude-install", + "claude-install": "claude:--version", + "claude-verify": "chatgpt.com", + "codex-download": "codex-install", + "codex-install": "codex:--version", + "codex-verify": "pi.dev", + "pi-download": "pi-install", + "pi-install": "pi:--version", + "pi-verify": "astral.sh", + "uv-download": "uv-install", + "uv-install": "uv:--version", + "uv-verify": "uv:tool install", + "fcc-install": "uv:tool update-shell", + "path-update": "uv:tool dir --bin", + "fcc-missing": "fcc-server:--version", + }.get(failure) + if forbidden is not None: + assert not any(forbidden in call for call in posix_harness.calls()) + + +def test_install_sh_dry_run_never_executes_commands( + posix_harness: PosixHarness, +) -> None: + result = posix_harness.run("--dry-run") + + assert result.returncode == 0, result.stderr + assert posix_harness.calls() == [] + assert "Dry run complete. No changes were made." in result.stdout + assert "Free Claude Code is installed and verified." not in result.stdout + + +def test_install_sh_rejects_broken_existing_client_without_replacing_it( + posix_harness: PosixHarness, +) -> None: + posix_harness.add_client("claude") + + result = posix_harness.run(fail_step="claude-verify") + + assert result.returncode != 0 + assert not any(call.startswith("download:") for call in posix_harness.calls()) + + +def test_install_sh_rejects_unparseable_existing_uv( + posix_harness: PosixHarness, +) -> None: + posix_harness.add_client("claude") + posix_harness.add_client("codex") + posix_harness.add_client("pi") + posix_harness.add_uv("not-a-version") + + result = posix_harness.run() + + assert result.returncode != 0 + assert not any("astral.sh" in call for call in posix_harness.calls()) + + +def test_install_sh_voice_flags_only_change_fcc_spec( + posix_harness: PosixHarness, +) -> None: + result = posix_harness.run("--voice-all", "--torch-backend", "cu130") + + assert result.returncode == 0, result.stderr + assert any( + "--torch-backend cu130 free-claude-code[voice,voice_local] @ " + "https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" + in call + for call in posix_harness.calls() + ) + + +def test_install_sh_rejects_invalid_options_before_mutation( + posix_harness: PosixHarness, +) -> None: + result = posix_harness.run("--torch-backend", "cu130") + + assert result.returncode != 0 + assert posix_harness.calls() == [] + + +def _powershells() -> tuple[str, ...]: + candidates = (shutil.which("pwsh"), shutil.which("powershell")) + return tuple(dict.fromkeys(path for path in candidates if path is not None)) + + +def _batch_client(name: str) -> str: + help_output = ( + "echo --extension, -e ^ Load an extension\n" + "echo --models ^ Scope models" + if name == "pi" + else "rem no product help" + ) + return f"""@echo off +echo {name}:%*>>"%CALL_LOG%" +if "%FAIL_STEP%"=="{name}-verify" exit /b 51 +if "%1"=="--version" echo {name} 1.0.0 +if "%1"=="--help" ( +{help_output} +) +exit /b 0 +""" + + +def _batch_npm() -> str: + return r"""@echo off +echo npm:%*>>"%CALL_LOG%" +if "%1"=="prefix" if "%2"=="-g" echo %FAKE_NPM_PREFIX%& exit /b 0 +if "%1"=="config" if "%2"=="get" if "%3"=="prefix" echo %FAKE_NPM_PREFIX%& exit /b 0 +exit /b 71 +""" + + +def _batch_uv(version: str) -> str: + return rf"""@echo off +echo uv:%*>>"%CALL_LOG%" +if "%1"=="--version" goto version +if "%1"=="tool" if "%2"=="install" goto install +if "%1"=="tool" if "%2"=="update-shell" goto update_shell +if "%1"=="tool" if "%2"=="dir" if "%3"=="--bin" goto tool_bin +exit /b 59 +:version +if "%FAIL_STEP%"=="uv-verify" exit /b 52 +echo uv {version} +exit /b 0 +:install +if "%FAIL_STEP%"=="fcc-install" exit /b 53 +if not exist "%FAKE_TOOL_BIN%" mkdir "%FAKE_TOOL_BIN%" +copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%FAKE_TOOL_BIN%\fcc-server.cmd" >nul +copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%FAKE_TOOL_BIN%\fcc-claude.cmd" >nul +copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%FAKE_TOOL_BIN%\fcc-pi.cmd" >nul +if not "%FAIL_STEP%"=="fcc-missing" copy /y "%FAKE_FIXTURES%\fcc-command.cmd" "%FAKE_TOOL_BIN%\fcc-codex.cmd" >nul +exit /b 0 +:update_shell +if "%FAIL_STEP%"=="path-update" exit /b 54 +exit /b 0 +:tool_bin +echo %FAKE_TOOL_BIN% +exit /b 0 +""" + + +@dataclass +class PowerShellHarness: + root: Path + bin_dir: Path + fixtures: Path + tool_bin: Path + log: Path + env: dict[str, str] + powershell: str + wrapper: Path + + def add_client(self, name: str) -> None: + _write_executable(self.bin_dir / f"{name}.cmd", _batch_client(name)) + + def add_unrelated_pi(self) -> None: + _write_executable(self.bin_dir / "pi.cmd", _batch_client("unrelated-pi")) + + def add_npm_prefix(self, prefix: Path) -> None: + prefix.mkdir(parents=True) + self.env["FAKE_NPM_PREFIX"] = str(prefix) + _write_executable(self.bin_dir / "npm.cmd", _batch_npm()) + + def add_uv(self, version: str) -> None: + _write_executable(self.bin_dir / "uv.cmd", _batch_uv(version)) + + def run(self, *args: str, fail_step: str = "") -> subprocess.CompletedProcess[str]: + env = self.env | {"FAIL_STEP": fail_step} + return subprocess.run( + [ + self.powershell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(self.wrapper), + *args, + ], + check=False, + capture_output=True, + text=True, + env=env, + ) + + def calls(self) -> list[str]: + if not self.log.exists(): + return [] + return self.log.read_text(encoding="utf-8").splitlines() + + +@pytest.fixture( + params=_powershells() or (None,), + ids=lambda path: Path(path).name if path is not None else "unavailable", +) +def powershell_harness( + tmp_path: Path, + request: pytest.FixtureRequest, +) -> PowerShellHarness: + powershell = request.param + if powershell is None or os.name != "nt": + pytest.skip("PowerShell installer scenarios run on Windows hosts") + + bin_dir = tmp_path / "bin" + fixtures = tmp_path / "fixtures" + tool_bin = tmp_path / "tool-bin" + home = tmp_path / "home" + local_app_data = tmp_path / "local-app-data" + app_data = tmp_path / "app-data" + log = tmp_path / "calls.log" + for path in (bin_dir, fixtures, tool_bin, home, local_app_data, app_data): + path.mkdir(parents=True) + + (fixtures / "claude-command.cmd").write_text( + _batch_client("claude"), encoding="utf-8" + ) + (fixtures / "codex-command.cmd").write_text( + _batch_client("codex"), encoding="utf-8" + ) + (fixtures / "pi-command.cmd").write_text(_batch_client("pi"), encoding="utf-8") + (fixtures / "uv-command.cmd").write_text(_batch_uv("0.11.28"), encoding="utf-8") + (fixtures / "fcc-command.cmd").write_text( + """@echo off +for %%I in ("%~f0") do set "FCC_NAME=%%~nI" +echo %FCC_NAME%:%*>>"%CALL_LOG%" +if "%FAIL_STEP%"=="fcc-verify" exit /b 55 +if "%FCC_NAME%"=="fcc-server" if "%1"=="--version" echo free-claude-code 3.5.18 +exit /b 0 +""", + encoding="utf-8", + ) + (fixtures / "claude-installer.ps1").write_text( + r"""if ($env:FAIL_STEP -eq "claude-install") { exit 61 } +$bin = Join-Path $env:USERPROFILE ".local\bin" +New-Item -ItemType Directory -Force -Path $bin | Out-Null +Copy-Item (Join-Path $env:FAKE_FIXTURES "claude-command.cmd") (Join-Path $bin "claude.cmd") -Force +Add-Content -LiteralPath $env:CALL_LOG -Value "claude-install" +""", + encoding="utf-8", + ) + (fixtures / "codex-installer.ps1").write_text( + r"""if ($env:FAIL_STEP -eq "codex-install") { exit 62 } +$bin = Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin" +New-Item -ItemType Directory -Force -Path $bin | Out-Null +Copy-Item (Join-Path $env:FAKE_FIXTURES "codex-command.cmd") (Join-Path $bin "codex.cmd") -Force +Add-Content -LiteralPath $env:CALL_LOG -Value "codex-install:$env:CODEX_NON_INTERACTIVE" +""", + encoding="utf-8", + ) + (fixtures / "pi-installer.ps1").write_text( + r"""if ($env:FAIL_STEP -eq "pi-install") { exit 64 } +$bin = if ($env:FAKE_NPM_PREFIX) { $env:FAKE_NPM_PREFIX } else { Join-Path $env:APPDATA "npm" } +New-Item -ItemType Directory -Force -Path $bin | Out-Null +Copy-Item (Join-Path $env:FAKE_FIXTURES "pi-command.cmd") (Join-Path $bin "pi.cmd") -Force +Add-Content -LiteralPath $env:CALL_LOG -Value "pi-install" +""", + encoding="utf-8", + ) + (fixtures / "uv-installer.ps1").write_text( + r"""if ($env:FAIL_STEP -eq "uv-install") { exit 63 } +$bin = Join-Path $env:USERPROFILE ".local\bin" +New-Item -ItemType Directory -Force -Path $bin | Out-Null +Copy-Item (Join-Path $env:FAKE_FIXTURES "uv-command.cmd") (Join-Path $bin "uv.cmd") -Force +Add-Content -LiteralPath $env:CALL_LOG -Value "uv-install" +""", + encoding="utf-8", + ) + + wrapper = tmp_path / "run-installer.ps1" + wrapper.write_text( + """Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +function Invoke-RestMethod { + [CmdletBinding()] + param([string] $Uri, [string] $OutFile) + + Add-Content -LiteralPath $env:CALL_LOG -Value "download:$Uri" + if ( + ($env:FAIL_STEP -eq "claude-download" -and $Uri.Contains("claude.ai")) -or + ($env:FAIL_STEP -eq "codex-download" -and $Uri.Contains("chatgpt.com")) -or + ($env:FAIL_STEP -eq "pi-download" -and $Uri.Contains("pi.dev")) -or + ($env:FAIL_STEP -eq "uv-download" -and $Uri.Contains("astral.sh")) + ) { + throw "simulated download failure" + } + if ($Uri.Contains("claude.ai")) { + $source = Join-Path $env:FAKE_FIXTURES "claude-installer.ps1" + } + elseif ($Uri.Contains("chatgpt.com")) { + $source = Join-Path $env:FAKE_FIXTURES "codex-installer.ps1" + } + elseif ($Uri.Contains("pi.dev")) { + $source = Join-Path $env:FAKE_FIXTURES "pi-installer.ps1" + } + elseif ($Uri.Contains("astral.sh")) { + $source = Join-Path $env:FAKE_FIXTURES "uv-installer.ps1" + } + else { + throw "unexpected installer URL: $Uri" + } + Copy-Item -LiteralPath $source -Destination $OutFile -Force +} +$installer = [scriptblock]::Create([IO.File]::ReadAllText($env:FCC_INSTALLER)) +& $installer @args +""", + encoding="utf-8", + ) + + system_root = os.environ["SYSTEMROOT"] + env = os.environ.copy() + env.update( + { + "PATH": os.pathsep.join( + [str(bin_dir), str(Path(system_root) / "System32"), system_root] + ), + "PATHEXT": ".COM;.EXE;.BAT;.CMD", + "USERPROFILE": str(home), + "LOCALAPPDATA": str(local_app_data), + "APPDATA": str(app_data), + "CALL_LOG": str(log), + "FAKE_FIXTURES": str(fixtures), + "FAKE_TOOL_BIN": str(tool_bin), + "FCC_INSTALLER": str(_repo_root() / "scripts" / "install.ps1"), + "FAIL_STEP": "", + } + ) + return PowerShellHarness( + tmp_path, bin_dir, fixtures, tool_bin, log, env, powershell, wrapper + ) + + +def test_install_ps1_fresh_install_is_verified( + powershell_harness: PowerShellHarness, +) -> None: + result = powershell_harness.run() + + assert result.returncode == 0, result.stderr + assert "Free Claude Code is installed and verified." in result.stdout + calls = powershell_harness.calls() + assert calls.index("claude-install") < calls.index("claude:--version") + assert calls.index("codex-install:1") < calls.index("codex:--version") + assert calls.index("pi-install") < calls.index("pi:--version") + assert calls.index("uv-install") < calls.index("uv:--version") + assert any( + call.startswith( + "uv:tool install --force --refresh-package free-claude-code " + '--python 3.14.0 "free-claude-code @ ' + 'https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip"' + ) + for call in calls + ) + assert not any(call.startswith("git:") for call in calls) + assert calls[-3:] == [ + "uv:tool update-shell", + "uv:tool dir --bin", + "fcc-server:--version", + ] + + +def test_install_ps1_preserves_valid_existing_tools( + powershell_harness: PowerShellHarness, +) -> None: + powershell_harness.add_client("claude") + powershell_harness.add_client("codex") + powershell_harness.add_client("pi") + powershell_harness.add_uv("0.11.7") + + result = powershell_harness.run() + + assert result.returncode == 0, result.stderr + assert not any(call.startswith("download:") for call in powershell_harness.calls()) + assert "leaving it unchanged" in result.stdout + + +def test_install_ps1_replaces_unrelated_pi_command( + powershell_harness: PowerShellHarness, +) -> None: + powershell_harness.add_client("claude") + powershell_harness.add_client("codex") + powershell_harness.add_unrelated_pi() + powershell_harness.add_uv("0.11.7") + + result = powershell_harness.run() + + assert result.returncode == 0, result.stderr + assert "is not Pi Coding Agent; installing Pi" in result.stdout + assert "pi-install" in powershell_harness.calls() + + +def test_install_ps1_discovers_custom_pi_npm_prefix( + powershell_harness: PowerShellHarness, +) -> None: + powershell_harness.add_client("claude") + powershell_harness.add_client("codex") + powershell_harness.add_npm_prefix(powershell_harness.root / "custom-npm") + powershell_harness.add_uv("0.11.7") + + result = powershell_harness.run() + + assert result.returncode == 0, result.stderr + calls = powershell_harness.calls() + assert "npm:prefix -g" in calls + assert "pi:--help" in calls + assert "pi:--version" in calls + + +def test_install_ps1_replaces_obsolete_uv( + powershell_harness: PowerShellHarness, +) -> None: + powershell_harness.add_client("claude") + powershell_harness.add_client("codex") + powershell_harness.add_client("pi") + powershell_harness.add_uv("0.5.9") + + result = powershell_harness.run() + + assert result.returncode == 0, result.stderr + assert "uv 0.5.9 is below 0.11.0" in result.stdout + assert "uv-install" in powershell_harness.calls() + + +@pytest.mark.parametrize( + "failure", + [ + "claude-download", + "claude-install", + "claude-verify", + "codex-download", + "codex-install", + "codex-verify", + "pi-download", + "pi-install", + "pi-verify", + "uv-download", + "uv-install", + "uv-verify", + "fcc-install", + "path-update", + "fcc-missing", + "fcc-verify", + ], +) +def test_install_ps1_stops_without_success_on_each_failure( + powershell_harness: PowerShellHarness, + failure: str, +) -> None: + result = powershell_harness.run(fail_step=failure) + + assert result.returncode != 0 + assert "Free Claude Code is installed and verified." not in result.stdout + forbidden = { + "claude-download": "claude-install", + "claude-install": "claude:--version", + "claude-verify": "chatgpt.com", + "codex-download": "codex-install", + "codex-install": "codex:--version", + "codex-verify": "pi.dev", + "pi-download": "pi-install", + "pi-install": "pi:--version", + "pi-verify": "astral.sh", + "uv-download": "uv-install", + "uv-install": "uv:--version", + "uv-verify": "uv:tool install", + "fcc-install": "uv:tool update-shell", + "path-update": "uv:tool dir --bin", + "fcc-missing": "fcc-server:--version", + }.get(failure) + if forbidden is not None: + assert not any(forbidden in call for call in powershell_harness.calls()) + + +def test_install_ps1_dry_run_never_executes_commands( + powershell_harness: PowerShellHarness, +) -> None: + result = subprocess.run( + [ + powershell_harness.powershell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(_repo_root() / "scripts" / "install.ps1"), + "-DryRun", + ], + check=False, + capture_output=True, + text=True, + env=powershell_harness.env, + ) + + assert result.returncode == 0, result.stderr + assert powershell_harness.calls() == [] + assert "Dry run complete. No changes were made." in result.stdout + assert "Free Claude Code is installed and verified." not in result.stdout + + +def test_install_ps1_rejects_broken_existing_client_without_replacing_it( + powershell_harness: PowerShellHarness, +) -> None: + powershell_harness.add_client("claude") + + result = powershell_harness.run(fail_step="claude-verify") + + assert result.returncode != 0 + assert not any(call.startswith("download:") for call in powershell_harness.calls()) + + +def test_install_ps1_rejects_unparseable_existing_uv( + powershell_harness: PowerShellHarness, +) -> None: + powershell_harness.add_client("claude") + powershell_harness.add_client("codex") + powershell_harness.add_client("pi") + powershell_harness.add_uv("not-a-version") + + result = powershell_harness.run() + + assert result.returncode != 0 + assert not any("astral.sh" in call for call in powershell_harness.calls()) + + +def test_install_ps1_voice_flags_only_change_fcc_spec( + powershell_harness: PowerShellHarness, +) -> None: + result = powershell_harness.run("-VoiceAll", "-TorchBackend", "cu130") + + assert result.returncode == 0, result.stderr + assert any( + '--torch-backend cu130 "free-claude-code[voice,voice_local] @ ' + 'https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip"' + in call + for call in powershell_harness.calls() + ) + + +def test_installers_use_native_clients_and_single_python_selection() -> None: + shell = (_repo_root() / "scripts" / "install.sh").read_text(encoding="utf-8") + powershell = (_repo_root() / "scripts" / "install.ps1").read_text(encoding="utf-8") + + for text in (shell, powershell): + assert "@anthropic-ai/claude-code" not in text + assert "@openai/codex" not in text + assert "@earendil-works/pi-coding-agent" not in text + assert "git+" not in text + assert "git --version" not in text + assert ( + "https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" + in text + ) + assert "python install" not in text + assert "--refresh-package" in text + assert "tool update-shell" in text + assert "--python" in text + + assert "https://pi.dev/install.sh" in shell + assert "https://pi.dev/install.ps1" in powershell + + +def test_readme_install_section_has_no_manual_git_prerequisite() -> None: + readme = (_repo_root() / "README.md").read_text(encoding="utf-8") + install_section = readme.split("### 1. Install Or Update", 1)[1].split( + "### 2. Start The Server", 1 + )[0] + + assert "Install Git" not in install_section + assert "official native installers" not in install_section + + +@pytest.mark.parametrize("powershell", _powershells()) +def test_install_ps1_falls_back_when_pshome_executable_is_unavailable( + tmp_path: Path, + powershell: str, +) -> None: + text = (_repo_root() / "scripts" / "install.ps1").read_text(encoding="utf-8") + body = _braced_body(text, "function Get-PowerShellExecutable") + fallback = tmp_path / "fallback" / "powershell.exe" + script = tmp_path / "test-powershell-resolution.ps1" + script.write_text( + f"""Set-StrictMode -Version Latest +function Get-ApplicationCommand {{ + param([string] $Name) + return [pscustomobject] @{{ Source = {str(fallback)!r} }} +}} +function Get-PowerShellExecutable {{ +{body} +}} +$resolved = Get-PowerShellExecutable -PowerShellHome {str(tmp_path / "missing")!r} +if ($resolved -ne {str(fallback)!r}) {{ + throw "Unexpected fallback: $resolved" +}} +""", + encoding="utf-8", + ) + + result = subprocess.run( + [powershell, "-NoProfile", "-File", str(script)], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/scripts/test_uninstallers.py b/tests/scripts/test_uninstallers.py new file mode 100644 index 0000000..ebe2287 --- /dev/null +++ b/tests/scripts/test_uninstallers.py @@ -0,0 +1,509 @@ +import os +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +import pytest + +FCC_COMMANDS = ( + "fcc-server", + "fcc-claude", + "fcc-codex", + "fcc-pi", + "fcc-init", + "free-claude-code", +) + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _write_executable(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + path.chmod(0o755) + + +def _powershells() -> tuple[str, ...]: + candidates = (shutil.which("pwsh"), shutil.which("powershell")) + return tuple(dict.fromkeys(path for path in candidates if path is not None)) + + +@dataclass +class PosixUninstallHarness: + home: Path + bin_dir: Path + tool_bin: Path + fcc_home: Path + log: Path + env: dict[str, str] + + def run( + self, + *args: str, + fail_step: str = "", + include_uv: bool = True, + ) -> subprocess.CompletedProcess[str]: + uv = self.bin_dir / "uv" + if not include_uv and uv.exists(): + uv.unlink() + return subprocess.run( + ["/bin/sh", str(_repo_root() / "scripts" / "uninstall.sh"), *args], + check=False, + capture_output=True, + text=True, + env=self.env | {"FAIL_STEP": fail_step}, + ) + + def calls(self) -> list[str]: + if not self.log.exists(): + return [] + return self.log.read_text(encoding="utf-8").splitlines() + + def remove_entry_points(self) -> None: + for name in FCC_COMMANDS: + (self.tool_bin / name).unlink(missing_ok=True) + + +@pytest.fixture +def posix_uninstall_harness(tmp_path: Path) -> PosixUninstallHarness: + if os.name == "nt": + pytest.skip("POSIX uninstaller scenarios run on POSIX hosts") + + home = tmp_path / "home" + bin_dir = home / ".local" / "bin" + tool_bin = tmp_path / "tool-bin" + fcc_home = home / ".fcc" + log = tmp_path / "calls.log" + for path in (bin_dir, tool_bin, fcc_home): + path.mkdir(parents=True) + (fcc_home / "config.json").write_text("{}", encoding="utf-8") + for name in FCC_COMMANDS: + _write_executable(tool_bin / name, "#!/bin/sh\nexit 0\n") + + _write_executable(bin_dir / "claude", "#!/bin/sh\nexit 0\n") + _write_executable(bin_dir / "codex", "#!/bin/sh\nexit 0\n") + _write_executable(bin_dir / "pi", "#!/bin/sh\nexit 0\n") + _write_executable( + bin_dir / "uv", + """#!/bin/sh +echo "uv:$*" >> "$CALL_LOG" +if [ "${1:-}" = "tool" ] && [ "${2:-}" = "dir" ] && [ "${3:-}" = "--bin" ]; then + if [ "$FAIL_STEP" = "tool-dir" ]; then + echo "tool directory unavailable" >&2 + exit 41 + fi + printf '%s\n' "$FAKE_TOOL_BIN" + exit 0 +fi +if [ "${1:-}" = "tool" ] && [ "${2:-}" = "uninstall" ]; then + if [ "$FAIL_STEP" = "uninstall" ]; then + echo "permission denied while removing tool" >&2 + exit 42 + fi + if [ "$FAIL_STEP" = "missing" ] || [ "$FAIL_STEP" = "stale-entrypoint" ]; then + echo 'Tool `free-claude-code` is not installed' >&2 + exit 2 + fi + for name in fcc-server fcc-claude fcc-codex fcc-pi fcc-init free-claude-code; do + /bin/rm -f "$FAKE_TOOL_BIN/$name" + done + echo "Uninstalled free-claude-code" + exit 0 +fi +exit 43 +""", + ) + _write_executable( + bin_dir / "rm", + """#!/bin/sh +echo "rm:$*" >> "$CALL_LOG" +if [ "$FAIL_STEP" = "purge" ]; then + echo "simulated purge failure" >&2 + exit 44 +fi +exec /bin/rm "$@" +""", + ) + + env = os.environ.copy() + env.update( + { + "HOME": str(home), + "PATH": f"{bin_dir}:/usr/bin:/bin", + "CALL_LOG": str(log), + "FAKE_TOOL_BIN": str(tool_bin), + "FAIL_STEP": "", + } + ) + env.pop("XDG_BIN_HOME", None) + return PosixUninstallHarness(home, bin_dir, tool_bin, fcc_home, log, env) + + +def test_uninstall_sh_removes_and_verifies_only_fcc( + posix_uninstall_harness: PosixUninstallHarness, +) -> None: + result = posix_uninstall_harness.run() + + assert result.returncode == 0, result.stderr + assert "Free Claude Code has been removed and verified." in result.stdout + assert not posix_uninstall_harness.fcc_home.exists() + assert all( + not (posix_uninstall_harness.tool_bin / name).exists() for name in FCC_COMMANDS + ) + assert (posix_uninstall_harness.bin_dir / "uv").exists() + assert (posix_uninstall_harness.bin_dir / "claude").exists() + assert (posix_uninstall_harness.bin_dir / "codex").exists() + assert (posix_uninstall_harness.bin_dir / "pi").exists() + assert posix_uninstall_harness.calls() == [ + "uv:tool dir --bin", + "uv:tool uninstall free-claude-code", + f"rm:-rf {posix_uninstall_harness.fcc_home}", + ] + + +def test_uninstall_sh_is_idempotent_when_tool_is_already_absent( + posix_uninstall_harness: PosixUninstallHarness, +) -> None: + posix_uninstall_harness.remove_entry_points() + + result = posix_uninstall_harness.run(fail_step="missing") + + assert result.returncode == 0, result.stderr + assert not posix_uninstall_harness.fcc_home.exists() + assert "already absent" in result.stdout + + +@pytest.mark.parametrize("failure", ["tool-dir", "uninstall", "stale-entrypoint"]) +def test_uninstall_sh_preserves_config_when_tool_removal_is_unconfirmed( + posix_uninstall_harness: PosixUninstallHarness, + failure: str, +) -> None: + result = posix_uninstall_harness.run(fail_step=failure) + + assert result.returncode != 0 + assert posix_uninstall_harness.fcc_home.exists() + assert "Free Claude Code has been removed and verified." not in result.stdout + assert not any(call.startswith("rm:") for call in posix_uninstall_harness.calls()) + + +def test_uninstall_sh_requires_uv_before_deleting_config( + posix_uninstall_harness: PosixUninstallHarness, +) -> None: + result = posix_uninstall_harness.run(include_uv=False) + + assert result.returncode != 0 + assert posix_uninstall_harness.fcc_home.exists() + assert "uv is required" in result.stderr + assert posix_uninstall_harness.calls() == [] + + +def test_uninstall_sh_reports_purge_failure_after_verified_tool_removal( + posix_uninstall_harness: PosixUninstallHarness, +) -> None: + result = posix_uninstall_harness.run(fail_step="purge") + + assert result.returncode != 0 + assert posix_uninstall_harness.fcc_home.exists() + assert all( + not (posix_uninstall_harness.tool_bin / name).exists() for name in FCC_COMMANDS + ) + assert "Free Claude Code has been removed and verified." not in result.stdout + + +def test_uninstall_sh_dry_run_is_non_mutating( + posix_uninstall_harness: PosixUninstallHarness, +) -> None: + result = posix_uninstall_harness.run("--dry-run") + + assert result.returncode == 0, result.stderr + assert posix_uninstall_harness.fcc_home.exists() + assert all( + (posix_uninstall_harness.tool_bin / name).exists() for name in FCC_COMMANDS + ) + assert posix_uninstall_harness.calls() == [] + assert "Dry run complete. No changes were made." in result.stdout + + +def test_uninstall_sh_rejects_invalid_options_before_mutation( + posix_uninstall_harness: PosixUninstallHarness, +) -> None: + result = posix_uninstall_harness.run("--unknown") + + assert result.returncode != 0 + assert posix_uninstall_harness.fcc_home.exists() + assert posix_uninstall_harness.calls() == [] + + +@dataclass +class PowerShellUninstallHarness: + home: Path + bin_dir: Path + tool_bin: Path + fcc_home: Path + log: Path + env: dict[str, str] + powershell: str + wrapper: Path + + def run( + self, + *, + fail_step: str = "", + include_uv: bool = True, + dry_run: bool = False, + ) -> subprocess.CompletedProcess[str]: + uv = self.bin_dir / "uv.cmd" + if not include_uv and uv.exists(): + uv.unlink() + return subprocess.run( + [ + self.powershell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(self.wrapper), + ], + check=False, + capture_output=True, + text=True, + env=self.env + | { + "FAIL_STEP": fail_step, + "UNINSTALL_DRY_RUN": "1" if dry_run else "0", + }, + ) + + def calls(self) -> list[str]: + if not self.log.exists(): + return [] + return self.log.read_text(encoding="utf-8").splitlines() + + def remove_entry_points(self) -> None: + for name in FCC_COMMANDS: + (self.tool_bin / f"{name}.cmd").unlink(missing_ok=True) + + +@pytest.fixture( + params=_powershells() or (None,), + ids=lambda path: Path(path).name if path is not None else "unavailable", +) +def powershell_uninstall_harness( + tmp_path: Path, + request: pytest.FixtureRequest, +) -> PowerShellUninstallHarness: + powershell = request.param + if powershell is None or os.name != "nt": + pytest.skip("PowerShell uninstaller scenarios run on Windows hosts") + + home = tmp_path / "home" + bin_dir = home / ".local" / "bin" + tool_bin = tmp_path / "tool-bin" + fcc_home = home / ".fcc" + log = tmp_path / "calls.log" + for path in (bin_dir, tool_bin, fcc_home): + path.mkdir(parents=True) + (fcc_home / "config.json").write_text("{}", encoding="utf-8") + for name in FCC_COMMANDS: + (tool_bin / f"{name}.cmd").write_text( + "@echo off\nexit /b 0\n", encoding="utf-8" + ) + for name in ("claude", "codex", "pi"): + (bin_dir / f"{name}.cmd").write_text("@echo off\nexit /b 0\n", encoding="utf-8") + + uv_commands = " ".join(FCC_COMMANDS) + (bin_dir / "uv.cmd").write_text( + rf"""@echo off +echo uv:%*>>"%CALL_LOG%" +if "%1"=="tool" if "%2"=="dir" if "%3"=="--bin" goto tool_bin +if "%1"=="tool" if "%2"=="uninstall" goto uninstall +exit /b 53 +:tool_bin +if "%FAIL_STEP%"=="tool-dir" echo tool directory unavailable 1>&2 & exit /b 51 +echo %FAKE_TOOL_BIN% +exit /b 0 +:uninstall +if "%FAIL_STEP%"=="uninstall" echo permission denied while removing tool 1>&2 & exit /b 52 +if "%FAIL_STEP%"=="missing" echo Tool `free-claude-code` is not installed 1>&2 & exit /b 2 +if "%FAIL_STEP%"=="stale-entrypoint" echo Tool `free-claude-code` is not installed 1>&2 & exit /b 2 +for %%C in ({uv_commands}) do del /q "%FAKE_TOOL_BIN%\%%C.cmd" 2>nul +echo Uninstalled free-claude-code +exit /b 0 +""", + encoding="utf-8", + ) + + wrapper = tmp_path / "run-uninstaller.ps1" + wrapper.write_text( + r"""Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +function Remove-Item { + [CmdletBinding()] + param( + [string] $LiteralPath, + [switch] $Recurse, + [switch] $Force + ) + Add-Content -LiteralPath $env:CALL_LOG -Value "remove:$LiteralPath" + if ($env:FAIL_STEP -eq "purge") { + throw "simulated purge failure" + } + Microsoft.PowerShell.Management\Remove-Item @PSBoundParameters +} +$installer = [scriptblock]::Create([IO.File]::ReadAllText($env:FCC_UNINSTALLER)) +if ($env:UNINSTALL_DRY_RUN -eq "1") { + & $installer -DryRun +} +else { + & $installer +} +""", + encoding="utf-8", + ) + + system_root = os.environ["SYSTEMROOT"] + env = os.environ.copy() + env.update( + { + "PATH": os.pathsep.join( + [str(bin_dir), str(Path(system_root) / "System32"), system_root] + ), + "PATHEXT": ".COM;.EXE;.BAT;.CMD", + "HOME": str(home), + "USERPROFILE": str(home), + "CALL_LOG": str(log), + "FAKE_TOOL_BIN": str(tool_bin), + "FCC_UNINSTALLER": str(_repo_root() / "scripts" / "uninstall.ps1"), + "FAIL_STEP": "", + "UNINSTALL_DRY_RUN": "0", + } + ) + return PowerShellUninstallHarness( + home, bin_dir, tool_bin, fcc_home, log, env, powershell, wrapper + ) + + +def test_uninstall_ps1_removes_and_verifies_only_fcc( + powershell_uninstall_harness: PowerShellUninstallHarness, +) -> None: + result = powershell_uninstall_harness.run() + + assert result.returncode == 0, result.stderr + assert "Free Claude Code has been removed and verified." in result.stdout + assert not powershell_uninstall_harness.fcc_home.exists() + assert all( + not (powershell_uninstall_harness.tool_bin / f"{name}.cmd").exists() + for name in FCC_COMMANDS + ) + assert (powershell_uninstall_harness.bin_dir / "uv.cmd").exists() + assert (powershell_uninstall_harness.bin_dir / "claude.cmd").exists() + assert (powershell_uninstall_harness.bin_dir / "codex.cmd").exists() + assert (powershell_uninstall_harness.bin_dir / "pi.cmd").exists() + assert powershell_uninstall_harness.calls() == [ + "uv:tool dir --bin", + "uv:tool uninstall free-claude-code", + f"remove:{powershell_uninstall_harness.fcc_home}", + ] + + +def test_uninstall_ps1_is_idempotent_when_tool_is_already_absent( + powershell_uninstall_harness: PowerShellUninstallHarness, +) -> None: + powershell_uninstall_harness.remove_entry_points() + + result = powershell_uninstall_harness.run(fail_step="missing") + + assert result.returncode == 0, result.stderr + assert not powershell_uninstall_harness.fcc_home.exists() + assert "already absent" in result.stdout + + +@pytest.mark.parametrize("failure", ["tool-dir", "uninstall", "stale-entrypoint"]) +def test_uninstall_ps1_preserves_config_when_tool_removal_is_unconfirmed( + powershell_uninstall_harness: PowerShellUninstallHarness, + failure: str, +) -> None: + result = powershell_uninstall_harness.run(fail_step=failure) + + assert result.returncode != 0 + assert powershell_uninstall_harness.fcc_home.exists() + assert "Free Claude Code has been removed and verified." not in result.stdout + assert not any( + call.startswith("remove:") for call in powershell_uninstall_harness.calls() + ) + + +def test_uninstall_ps1_requires_uv_before_deleting_config( + powershell_uninstall_harness: PowerShellUninstallHarness, +) -> None: + result = powershell_uninstall_harness.run(include_uv=False) + + assert result.returncode != 0 + assert powershell_uninstall_harness.fcc_home.exists() + assert "uv is required" in result.stderr + assert powershell_uninstall_harness.calls() == [] + + +def test_uninstall_ps1_reports_purge_failure_after_verified_tool_removal( + powershell_uninstall_harness: PowerShellUninstallHarness, +) -> None: + result = powershell_uninstall_harness.run(fail_step="purge") + + assert result.returncode != 0 + assert powershell_uninstall_harness.fcc_home.exists() + assert all( + not (powershell_uninstall_harness.tool_bin / f"{name}.cmd").exists() + for name in FCC_COMMANDS + ) + assert "Free Claude Code has been removed and verified." not in result.stdout + + +def test_uninstall_ps1_dry_run_is_non_mutating( + powershell_uninstall_harness: PowerShellUninstallHarness, +) -> None: + result = powershell_uninstall_harness.run(dry_run=True) + + assert result.returncode == 0, result.stderr + assert powershell_uninstall_harness.fcc_home.exists() + assert all( + (powershell_uninstall_harness.tool_bin / f"{name}.cmd").exists() + for name in FCC_COMMANDS + ) + assert powershell_uninstall_harness.calls() == [] + assert "Dry run complete. No changes were made." in result.stdout + + +def test_uninstallers_guard_running_commands_and_preserve_shared_owners() -> None: + shell = (_repo_root() / "scripts" / "uninstall.sh").read_text(encoding="utf-8") + powershell = (_repo_root() / "scripts" / "uninstall.ps1").read_text( + encoding="utf-8" + ) + + assert "pgrep" in shell + assert "Get-Process" in powershell + for text in (shell, powershell): + for command in FCC_COMMANDS: + assert command in text + assert "npm uninstall" not in text + assert "uv self uninstall" not in text + assert "uv python uninstall" not in text + assert "is not installed" in text + assert "no tool" not in text + assert "nothing to uninstall" not in text + + +def test_readme_uninstall_uses_raw_urls_and_verification_contract() -> None: + text = (_repo_root() / "README.md").read_text(encoding="utf-8") + + assert ( + 'curl -fsSL "https://raw.githubusercontent.com/' + 'Alishahryar1/free-claude-code/main/scripts/uninstall.sh" | sh' + ) in text + assert ( + '& ([scriptblock]::Create((irm "https://raw.githubusercontent.com/' + 'Alishahryar1/free-claude-code/main/scripts/uninstall.ps1")))' + ) in text + assert "verifies every FCC command is gone" in text diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9586f2c --- /dev/null +++ b/uv.lock @@ -0,0 +1,2505 @@ +version = 1 +revision = 3 +requires-python = ">=3.14.0" + +[[package]] +name = "accelerate" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "safetensors" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/75/94cd5d389649578aca399e5aa822637eec18319a1dadc400ffe2f9a7493f/accelerate-1.14.0.tar.gz", hash = "sha256:41b9c4377a54e0b460a959b0defa1b736e4ca0a2373252d9a539964c2afe3c8d", size = 412167, upload-time = "2026-06-11T13:45:52.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/db/253133d7e7cb40d3af384bb2f5c0b4a2b7fdcffbc95c688cc67a20a3c103/accelerate-1.14.0-py3-none-any.whl", hash = "sha256:e94390c2863b873be18f623f9df48a0d8fe5eff13ea7f1a00092b0a7904888c6", size = 389246, upload-time = "2026-06-11T13:45:50.477Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "audioop-lts" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, + { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, + { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, + { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, + { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, +] + +[[package]] +name = "audioread" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "standard-aifc" }, + { name = "standard-sunau" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/4a/874ecf9b472f998130c2b5e145dcdb9f6131e84786111489103b66772143/audioread-3.1.0.tar.gz", hash = "sha256:1c4ab2f2972764c896a8ac61ac53e261c8d29f0c6ccd652f84e18f08a4cab190", size = 20082, upload-time = "2025-10-26T19:44:13.484Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/16/fbe8e1e185a45042f7cd3a282def5bb8d95bb69ab9e9ef6a5368aa17e426/audioread-3.1.0-py3-none-any.whl", hash = "sha256:b30d1df6c5d3de5dcef0fb0e256f6ea17bdcf5f979408df0297d8a408e2971b4", size = 23143, upload-time = "2025-10-26T19:44:12.016Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[[package]] +name = "cuda-bindings" +version = "13.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/99/0042dc5e98e3364480b1aaabc0f5c150d037825b264bba35ac7a883e46ee/cuda_bindings-13.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c7e6e89cdfc9b34f16a065cc6ad6c4bab19ce5dcef8da3ace8ad10bda899fa0", size = 11594384, upload-time = "2025-10-21T15:09:21.938Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c4/a931a90ce763bd7d587e18e73e4ce246b8547c78247c4f50ee24efc0e984/cuda_bindings-13.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e93866465e7ff4b7ebdf711cf9cd680499cd875f992058c68be08d4775ac233d", size = 11920899, upload-time = "2025-10-21T15:09:26.306Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/ec611e27ba48a9056f3b0610c5e27727e539f3905356cfe07acea18e772c/cuda_bindings-13.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed06ef3507bd0aefb0da367e3d15676a8c7443bd68a88f298562d60b41078c20", size = 11521928, upload-time = "2025-10-21T15:09:30.714Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2e/02cebf281ef5201b6bb9ea193b1a4d26e6233c46571cfb04c4a7dede12b9/cuda_bindings-13.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ab845487ca2c14accdcb393a559a3070469ea4b591d05e6ef439471f47f3e24", size = 11902749, upload-time = "2025-10-21T15:09:32.688Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/5e/db279a3bfbd18d59d0598922a3b3c1454908d0969e8372260afec9736376/cuda_pathfinder-1.3.4-py3-none-any.whl", hash = "sha256:fb983f6e0d43af27ef486e14d5989b5f904ef45cedf40538bfdcbffa6bb01fb2", size = 30878, upload-time = "2026-02-11T18:50:31.008Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, +] + +[package.optional-dependencies] +cudart = [ + { name = "nvidia-cuda-runtime" }, +] +cufft = [ + { name = "nvidia-cufft" }, +] +cufile = [ + { name = "nvidia-cufile" }, +] +cupti = [ + { name = "nvidia-cuda-cupti" }, +] +curand = [ + { name = "nvidia-curand" }, +] +cusolver = [ + { name = "nvidia-cusolver" }, +] +cusparse = [ + { name = "nvidia-cusparse" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc" }, +] +nvtx = [ + { name = "nvidia-nvtx" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "discord-py" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "audioop-lts" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/57/9a2d9abdabdc9db8ef28ce0cf4129669e1c8717ba28d607b5ba357c4de3b/discord_py-2.7.1.tar.gz", hash = "sha256:24d5e6a45535152e4b98148a9dd6b550d25dc2c9fb41b6d670319411641249da", size = 1106326, upload-time = "2026-03-03T18:40:46.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/a7/17208c3b3f92319e7fad259f1c6d5a5baf8fd0654c54846ced329f83c3eb/discord_py-2.7.1-py3-none-any.whl", hash = "sha256:849dca2c63b171146f3a7f3f8acc04248098e9e6203412ce3cf2745f284f7439", size = 1227550, upload-time = "2026-03-03T18:40:44.492Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "fastapi" +version = "0.139.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "fastar" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "pydantic-extra-types" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/5a/500ec4deaa9a5d6bc7909cbd7b252fa37fe80d418c55a65ce5ed11c53505/fastapi_cli-0.0.21.tar.gz", hash = "sha256:457134b8f3e08d2d203a18db923a18bbc1a01d9de36fbe1fa7905c4d02a0e5c0", size = 19664, upload-time = "2026-02-11T15:27:59.65Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/cf/d1f3ea2a1661d80c62c7b1537184ec28ec832eefb7ad1ff3047813d19452/fastapi_cli-0.0.21-py3-none-any.whl", hash = "sha256:57c6e043694c68618eee04d00b4d93213c37f5a854b369d2871a77dfeff57e91", size = 12391, upload-time = "2026-02-11T15:27:58.181Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "fastapi-cloud-cli" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastar" }, + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/59/3def056ec8350df78a0786b7ca40a167cbf28ac26552ced4e19e1f83e872/fastapi_cloud_cli-0.12.0.tar.gz", hash = "sha256:c897d1d5e27f5b4148ed2601076785155ec8fb385a6a62d3e8801880f929629f", size = 38508, upload-time = "2026-02-13T19:39:57.877Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/6f/badabb5a21388b0af2b9cd0c2a5d81aaecfca57bf382872890e802eaed98/fastapi_cloud_cli-0.12.0-py3-none-any.whl", hash = "sha256:9c666c2ab1684cee48a5b0a29ac1ae0bd395b9a13bf6858448b4369ea68beda1", size = 27735, upload-time = "2026-02-13T19:39:58.705Z" }, +] + +[[package]] +name = "fastar" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/0f/0aeb3fc50046617702acc0078b277b58367fd62eb727b9ec733ae0e8bbcc/fastar-0.11.0.tar.gz", hash = "sha256:aa7f100f7313c03fdb20f1385927ba95671071ba308ad0c1763fef295e1895ce", size = 70238, upload-time = "2026-04-13T17:11:17.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/cd/3644c48ecac456f928c12d47ec3bed36c36555b17c3859856f1ff860265d/fastar-0.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:71375bd6f03c2a43eb47bd949ea38ff45434917f9cdac79675c5b9f60de4fa73", size = 707860, upload-time = "2026-04-13T17:10:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/69/ca/dee04476ae3626b2b040a60ad84628f77e1ffd8444232f2426b0ca1e0d7e/fastar-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:eddfd9cab16e19ae247fe44bf992cb403ccfe27d3931d6de29a4695d95ad386c", size = 628216, upload-time = "2026-04-13T17:09:45.355Z" }, + { url = "https://files.pythonhosted.org/packages/dc/5e/9395c7353d079cb4f5be0f7982ce0dc9f2e7dec5fd175eef466729d6023a/fastar-0.11.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7c371f1d4386c699018bb64eb2fa785feacf32785559049d2bb72fe4af023f53", size = 864378, upload-time = "2026-04-13T17:09:14.611Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/1e4f67148223ff219612b6281a6000357abbcc2417964fa5c83f11d68fce/fastar-0.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cad7fa41e3e66554387481c1a09365e4638becd322904932674159d5f4046728", size = 760921, upload-time = "2026-04-13T17:07:59.138Z" }, + { url = "https://files.pythonhosted.org/packages/0f/82/09d11fb6d12f17993ffaf32ffd30c3c121a11e2966e84f19fb6f66430118/fastar-0.11.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cf36652fa71b83761717c9899b98732498f8a2cb6327ff16bbf07f6be85c3437", size = 757012, upload-time = "2026-04-13T17:08:14.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/1f/5aeeacc4cb65615e2c9292cd9c5b0cd6fb6d2e6ee472ca6adc6c1b1b22ef/fastar-0.11.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f68ff8c17833053da4841720e95edde80ce45bb994b6b7d51418dddaac70ee47", size = 924510, upload-time = "2026-04-13T17:08:28.741Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1a/1e5bdabbeaf2e856928956292609f2ff6a650f94480fb8afaca30229e483/fastar-0.11.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4563ed37a12ea1cdc398af8571258d24b988bf342b7b3bf5451bd5891243280c", size = 816602, upload-time = "2026-04-13T17:08:59.461Z" }, + { url = "https://files.pythonhosted.org/packages/87/24/f960147910da3bed41a3adfcb026e17d5f50f4cf467a3324237a7088f61a/fastar-0.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cee63c9875cba3b70dc44338c560facc5d6e763047dcc4a30501f9a68cf5f890", size = 819452, upload-time = "2026-04-13T17:09:29.926Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f4/3e77d7901d5707fd7f8a352e153c8ae09ea974e6fabad0b7c4eb9944b8d4/fastar-0.11.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:bd76bfffae6d0a91f4ac4a612f721e7aec108db97dccdd120ae063cd66959f27", size = 885254, upload-time = "2026-04-13T17:08:44.285Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/1585edd5ec47782ae93cd94edf05828e0ab02ef00aec00aea4194a600464/fastar-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f5b707501ec01c1bc0518f741f01d322e50c9adc19a451aa24f67a2316e9397", size = 971496, upload-time = "2026-04-13T17:10:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e9/6874c9d1236ded565a0bed54b320ac9f165f287b1d89490fb70f9f323c81/fastar-0.11.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:37c0b5a88a657839aad98b0a6c9e4ac4c2c15d6b49c44ee3935c6b08e9d3e479", size = 1034685, upload-time = "2026-04-13T17:10:34.063Z" }, + { url = "https://files.pythonhosted.org/packages/14/d8/4ab20613ce2983427aee958e39be878dba874aa227c530a845e32429c4f6/fastar-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6c55f536c62a6efb180c1af0d5182948bff576bbfe6276e8e1359c9c7d2215d8", size = 1072675, upload-time = "2026-04-13T17:10:50.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/5ac3b7c20ce4b08f011dd2b979f96caabe64f9b10b157f211ea91bdfadca/fastar-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3082eeca59e189b9039335862f4c2780c0c8871d656bfdf559db4414a105b251", size = 1029330, upload-time = "2026-04-13T17:11:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e7/37cd6a1d4e288292170b64e19d79ecce2a7de8bb76790323399a2abc4619/fastar-0.11.0-cp314-cp314-win32.whl", hash = "sha256:b201a0a4e29f9fec2a177e13154b8725ec65ab9f83bd6415483efaa2aa18344b", size = 453940, upload-time = "2026-04-13T17:11:48.713Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1c/795c878b1ee29d79021cf8ed81f18f2b25ccde58453b0d34b9bdc7e025ea/fastar-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:868fddb26072a43e870a8819134b9f80ee602931be5a76e6fb873e04da343637", size = 486334, upload-time = "2026-04-13T17:11:34.882Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a4/113f104301df8bddcc0b3775b611a30cb7610baa3add933c7ccac9386467/fastar-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:3db39c9cc42abb0c780a26b299f24dfbc8be455985e969e15336d70d7b2f833b", size = 461534, upload-time = "2026-04-13T17:11:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a6/5c5f2c2c8e0c63e56a5636ebc7721589c889e94c0092cec7eb28ae7207e6/fastar-0.11.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:49c3299dec5e125e7ebaa27545714da9c7391777366015427e0ae62d548b442b", size = 707156, upload-time = "2026-04-13T17:10:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/df/f7/982c01b61f0fc135ad2b16d01e6d0ee53cf8791e68827f5f7c5a65b2e5b1/fastar-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3328ed1ed56d31f5198350b17dd60449b8d6b9d47abb4688bab6aef4450a165b", size = 627032, upload-time = "2026-04-13T17:09:46.978Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c3/38f1dac77ae0c71c37b176277c96d830796b8ce2fe69705f917829b53829/fastar-0.11.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:bd3eca3bbfec84a614bcb4143b4ad4f784d0895babc26cfc88436af88ca23c7a", size = 864403, upload-time = "2026-04-13T17:09:16.58Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/e69c363bdb3e5a5848e937b662b5469581ee6682c51bc1c0556494773929/fastar-0.11.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff86a967acb0d621dd24063dda090daa67bf4993b9570e97fe156de88a9006ca", size = 759480, upload-time = "2026-04-13T17:08:00.599Z" }, + { url = "https://files.pythonhosted.org/packages/3b/29/4d8737590c2a6357d614d7cc7288e8f68e7e449680b8922997cc4349e65e/fastar-0.11.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:86eaf7c0e985d93a7734168be2fb232b2a8cca53e41431c2782d7c12b12c03b1", size = 756219, upload-time = "2026-04-13T17:08:15.699Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ec/400de7b3b7d48801908f19cf5462177104395799472671b3e8152b2b04ca/fastar-0.11.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91f07b0b8eb67e2f177733a1f884edad7dfb9f8977ffef15927b20cb9604027d", size = 923669, upload-time = "2026-04-13T17:08:30.574Z" }, + { url = "https://files.pythonhosted.org/packages/5d/01/8926c53da923fed7ab4b96e7fbf7f73b663beb4f02095b654d6fab46f9ad/fastar-0.11.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f85c896885eb4abf1a635d54dea22cac6ae48d04fc2ea26ae652fcf1febe1220", size = 815729, upload-time = "2026-04-13T17:09:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/89/f0/5fef4c7946e352651b504b1a4235dac3505e7cfd24020788ab50552e84bf/fastar-0.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:075c07095c8de4b774ba8f28b9c0a02b1a2cd254da50cbe464dd3bb2432e9158", size = 819812, upload-time = "2026-04-13T17:09:31.907Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c8/0ebc3298b4a45e7bddc50b169ae6a6f5b80c939394d4befe6e60de535ee7/fastar-0.11.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:07f028933820c65750baf3383b807ecce1cd9385cf00ce192b79d263ad6b856c", size = 884074, upload-time = "2026-04-13T17:08:45.802Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9f/7baa4cdff8d6fbca41fa5c764b48a941fed8a9ec6c4cc92de65895a28299/fastar-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:039f875efa0f01fa43c20bf4e2fc7305489c61d0ac76eda991acfba7820a0e63", size = 969450, upload-time = "2026-04-13T17:10:18.667Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dc/1ebbfb58a47056ba866494f19efbcdd2ba2897096b94f36e796594b4d05b/fastar-0.11.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:fff12452a9a5c6814a012445f26365541cc3d99dcca61f09762e6a389f7a32ea", size = 1033775, upload-time = "2026-04-13T17:10:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/ce4e3914066f08c99eb8c32952cc07c1a013e81b1db1b0f598130bf6b974/fastar-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2bf733e09f942b6fa876efe30a90508d1f4caef5630c00fb2a84fba355873712", size = 1072158, upload-time = "2026-04-13T17:10:52.497Z" }, + { url = "https://files.pythonhosted.org/packages/03/2a/6bca72992c84151c387cc6558f3867f5ebe5fb3684ee6fa9b76280ba4b8e/fastar-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d1531fa848fdd3677d2dce0a4b436ea64d9ae38fb8babe2ddbc180dd153cb7a3", size = 1028577, upload-time = "2026-04-13T17:11:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/83/18/7a7c15657a3da5569b26fc51cde6a80f8d84cb54b3b1aea6d74a103db4ad/fastar-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:5744551bc67c6fc6581cbd0e34a0fd6e2cd0bd30b43e94b1c3119cf35064b162", size = 453601, upload-time = "2026-04-13T17:11:53.726Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/331b59a6de279f3ad75c10c02c40a12f21d64a437d9c3d6f1af2dcbd7a76/fastar-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f4ce44e3b56c47cf38244b98d29f269b259740a580c47a2552efa5b96a5458fb", size = 486436, upload-time = "2026-04-13T17:11:40.089Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fd/5390ec4f49100f3ecb9968a392f9e6d039f1e3fe0ecd28443716ff01e589/fastar-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:76c1359314355eafbc6989f20fb1ad565a3d10200117923b9da765a17e2f6f11", size = 461049, upload-time = "2026-04-13T17:11:25.918Z" }, +] + +[[package]] +name = "filelock" +version = "3.24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/a8/dae62680be63cbb3ff87cfa2f51cf766269514ea5488479d42fec5aa6f3a/filelock-3.24.2.tar.gz", hash = "sha256:c22803117490f156e59fafce621f0550a7a853e2bbf4f87f112b11d469b6c81b", size = 37601, upload-time = "2026-02-16T02:50:45.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/04/a94ebfb4eaaa08db56725a40de2887e95de4e8641b9e902c311bfa00aa39/filelock-3.24.2-py3-none-any.whl", hash = "sha256:667d7dc0b7d1e1064dd5f8f8e80bdac157a6482e8d2e02cd16fd3b6b33bd6556", size = 24152, upload-time = "2026-02-16T02:50:44Z" }, +] + +[[package]] +name = "free-claude-code" +version = "4.0.0" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "discord-py" }, + { name = "fastapi", extra = ["standard"] }, + { name = "httpx", extra = ["socks"] }, + { name = "jsonschema" }, + { name = "loguru" }, + { name = "markdown-it-py" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "python-telegram-bot" }, + { name = "tiktoken" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +voice = [ + { name = "grpcio" }, + { name = "grpcio-tools" }, + { name = "nvidia-riva-client" }, +] +voice-local = [ + { name = "accelerate" }, + { name = "librosa" }, + { name = "torch" }, + { name = "transformers" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "accelerate", marker = "extra == 'voice-local'", specifier = ">=1.14.0" }, + { name = "aiohttp", specifier = ">=3.14.1" }, + { name = "discord-py", specifier = ">=2.7.1" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.139.0" }, + { name = "grpcio", marker = "extra == 'voice'", specifier = ">=1.81.1" }, + { name = "grpcio-tools", marker = "extra == 'voice'", specifier = ">=1.81.1" }, + { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, + { name = "jsonschema", specifier = ">=4.25.0" }, + { name = "librosa", marker = "extra == 'voice-local'", specifier = ">=0.10.0" }, + { name = "loguru", specifier = ">=0.7.0" }, + { name = "markdown-it-py", specifier = ">=4.2.0" }, + { name = "nvidia-riva-client", marker = "extra == 'voice'", specifier = ">=2.26.0" }, + { name = "openai", specifier = ">=2.44.0" }, + { name = "pydantic", specifier = ">=2.13.4" }, + { name = "pydantic-settings", specifier = ">=2.14.2" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "python-telegram-bot", specifier = ">=22.8" }, + { name = "tiktoken", specifier = ">=0.13.0" }, + { name = "torch", marker = "extra == 'voice-local'", specifier = ">=2.12.1", index = "https://download.pytorch.org/whl/cu130" }, + { name = "transformers", marker = "extra == 'voice-local'", specifier = ">=5.13.0" }, + { name = "uvicorn", specifier = ">=0.50.0" }, +] +provides-extras = ["voice", "voice-local"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "ruff", specifier = ">=0.15.20" }, + { name = "ty", specifier = ">=0.0.56" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, +] + +[[package]] +name = "grpcio" +version = "1.81.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/b5/1ff353970a87eda4c98251e34d2dfd214abd4982dc89119c9252a2a482d2/grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b", size = 13026582, upload-time = "2026-06-11T12:46:51.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/58/19414622b1bf6981bc9c05a365bd548e71876c89000083b3af489251e9c0/grpcio-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:506f48f2f9c29b143fca3dad7b0d518c188b6c9648c75a2ae6e2d9f2c13a060b", size = 6055336, upload-time = "2026-06-11T12:46:20.557Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/2ec88adb92b0eba970dd0e0e7dd086341daa3c75eba4f735f9e44bf684b0/grpcio-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d865db4a6318e1c1bea83292e0ed231090538fc4ca45425b0f0480eb338bbc6e", size = 12056279, upload-time = "2026-06-11T12:46:24.255Z" }, + { url = "https://files.pythonhosted.org/packages/41/36/e8c5f8c6ec71de73733695ebc809e98b178b534ec6d8eaa31a7ebab4ad4c/grpcio-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2aa72e3ce1770317ef534f63d397b55e130725f5149bd36077c3b539019db27", size = 6608225, upload-time = "2026-06-11T12:46:27.601Z" }, + { url = "https://files.pythonhosted.org/packages/30/22/96fc577a845ab093326d9ab1adb874bd4936c8cf98ac8ed2f3db13a0a2fb/grpcio-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0490c30c261eded63f3f354979f9dc4502a9fb944cccb60cd9dc85f5a7349854", size = 7306576, upload-time = "2026-06-11T12:46:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/76/7b/61dab5d5969f28d97fb1009cead1df0a5cd987d3315e1b37f18a4449f8bc/grpcio-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:410482da976329fe5f4067270401b12cf2bd552ff8020f054ecfaddb5475f9d6", size = 6812165, upload-time = "2026-06-11T12:46:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/6e501929d4f5f96462fd82fd9f0f06e5f9612207582b862868d68757b27d/grpcio-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3657301562ac3cb8018d30d0d3ebfa39932239f7b5703422057ef14b69949f5", size = 7422962, upload-time = "2026-06-11T12:46:36.511Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7e/f2157589e66daa78ebb3165942d05a08bdea93b9d11c2bc1e172aef89685/grpcio-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:24c8e57504c8f45b237e40b99262d181071e5099a07053695b75d97bb53053a0", size = 8408176, upload-time = "2026-06-11T12:46:39.803Z" }, + { url = "https://files.pythonhosted.org/packages/da/df/c6717fef716e00d235ffb96123baf6dce76d6004f6233fa767c502861460/grpcio-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b427c19380991a4eaab2f6144b64b99b412043314c6bf4ab544f97bb31ee4190", size = 7846681, upload-time = "2026-06-11T12:46:43.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/84/3502e9f210a6a5c4438c8aca3f88edd2e04f6a27f3d41b26cf0a0024b096/grpcio-1.81.1-cp314-cp314-win32.whl", hash = "sha256:61233fe8951e5c85dff81c2458b6528624760166946b5b47ea150a589168411f", size = 4264615, upload-time = "2026-06-11T12:46:45.741Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/4af731ff7492c68a96e4c71bfd0f4590acde92b31c6fe4894e6465c10ff6/grpcio-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:3768a5ff1b2125e6f552e561b6b2dca0e64982d8949689b4df145cf8b98d7821", size = 5070275, upload-time = "2026-06-11T12:46:48.486Z" }, +] + +[[package]] +name = "grpcio-tools" +version = "1.81.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/b3/1c5951352d6777fd7f99a0ccee04617fdfd8a5dbf2918a1f58c8b2b280b8/grpcio_tools-1.81.1.tar.gz", hash = "sha256:a22a3870180927fdd84e2b27d079ef5b7f5f8c6110181b6736afc17a463481f1", size = 6236155, upload-time = "2026-06-11T12:51:21.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/08/e581ad42ae517a61172285047e4d710e2ac75f2f1915f7c91f284254e6d5/grpcio_tools-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:7d168ea26390717d0462c0d0408331dc98a60fc7f7e6118afac9b73f5a66d87c", size = 2585944, upload-time = "2026-06-11T12:50:54.528Z" }, + { url = "https://files.pythonhosted.org/packages/78/c8/200d90ebad685af7eea5ff7e0360c504dd01ec053fe0f1f9c4abe3ea2d5a/grpcio_tools-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:43c528655b226375013036692d8db4cd59060c1f41dd62c77f4d17b69f6ce828", size = 5813492, upload-time = "2026-06-11T12:50:57.291Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/60da2a1af37aa8eb47308cec24d9f7709a8976fdec3a53fd35b56b358326/grpcio_tools-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a9c6fcc68c9d5a208967bfe4fd3224d3c3be9a950c3e827e8f4b17e15c2dc555", size = 2634991, upload-time = "2026-06-11T12:50:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7f/dede28b579ae9bf9079ba1aa913e8088d1dc0cdbe21c85caa22f0790cad2/grpcio_tools-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:a987c85dcbe1b32066d7acd46266d1a428aecbd629331bf5b853e74c835bf876", size = 2957913, upload-time = "2026-06-11T12:51:02.31Z" }, + { url = "https://files.pythonhosted.org/packages/4c/38/4de2118adb58ec7ffba65ec623b5836db769665c192517cbf187db3f6145/grpcio_tools-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a882382507bb5ec6d7edc9648053dfd3bc8f9285cde56a6fa9b9a83b4bd07f1c", size = 2697709, upload-time = "2026-06-11T12:51:05.016Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e1/762ced51059e4f694fd337ecae491581d42a4e61dcb0415d8c5c60e6ddcb/grpcio_tools-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7746e508d4239a02f7e93638be5bc0ebb0120ddb796f7506aaae9d47a4599d97", size = 3151884, upload-time = "2026-06-11T12:51:07.593Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/9823090dc801e7229944874e7429c3b98e741ac778d8dc373f60240e1c43/grpcio_tools-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fc3d2a41a7a4467fa03b391394fffada9291fe8feebc8679b526f6bc36942b25", size = 3710404, upload-time = "2026-06-11T12:51:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/64/4e/4eae98d02148cb6f9f452f09942afba407afa6851e6c1fddc5ae9ec0b4ed/grpcio_tools-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:21bb3ba90e6d8df1ff663d4ee39a4e5b25a64e8ed4902476ca9ded0954d3917a", size = 3370525, upload-time = "2026-06-11T12:51:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/e0/3e/2206e597a128da6a03a6106d2eaf2c3e72c7d80843d4be933e3a3d10d02a/grpcio_tools-1.81.1-cp314-cp314-win32.whl", hash = "sha256:3dca56016d90a710c4d9861bae793dc089c1430a90c79ce672e948ddb65fa539", size = 1030582, upload-time = "2026-06-11T12:51:14.906Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f2/bbeef86c687225b7bbc7c0acdfbd25c8bcaa3f5b1c941db053e5c3d9e859/grpcio_tools-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:cb08172b7b629e75cb33866928d319a3196540a725eaab628ba721007140f1af", size = 1207490, upload-time = "2026-06-11T12:51:17.598Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/92/ec9ad04d0b5728dca387a45af7bc98fbb0d73b2118759f5f6038b61a57e8/hf_xet-1.4.3.tar.gz", hash = "sha256:8ddedb73c8c08928c793df2f3401ec26f95be7f7e516a7bee2fbb546f6676113", size = 670477, upload-time = "2026-03-31T22:40:07.874Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/36/3e8f85ca9fe09b8de2b2e10c63b3b3353d7dda88a0b3d426dffbe7b8313b/hf_xet-1.4.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5251d5ece3a81815bae9abab41cf7ddb7bcb8f56411bce0827f4a3071c92fdc6", size = 3801019, upload-time = "2026-03-31T22:39:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9c/defb6cb1de28bccb7bd8d95f6e60f72a3d3fa4cb3d0329c26fb9a488bfe7/hf_xet-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1feb0f3abeacee143367c326a128a2e2b60868ec12a36c225afb1d6c5a05e6d2", size = 3558746, upload-time = "2026-03-31T22:39:54.766Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/8d001191893178ff8e826e46ad5299446e62b93cd164e17b0ffea08832ec/hf_xet-1.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b301fc150290ca90b4fccd079829b84bb4786747584ae08b94b4577d82fb791", size = 4207692, upload-time = "2026-03-31T22:39:46.246Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/6790b402803250e9936435613d3a78b9aaeee7973439f0918848dde58309/hf_xet-1.4.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d972fbe95ddc0d3c0fc49b31a8a69f47db35c1e3699bf316421705741aab6653", size = 3986281, upload-time = "2026-03-31T22:39:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/51/56/ea62552fe53db652a9099eda600b032d75554d0e86c12a73824bfedef88b/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c5b48db1ee344a805a1b9bd2cda9b6b65fe77ed3787bd6e87ad5521141d317cd", size = 4187414, upload-time = "2026-03-31T22:40:04.951Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f5/bc1456d4638061bea997e6d2db60a1a613d7b200e0755965ec312dc1ef79/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22bdc1f5fb8b15bf2831440b91d1c9bbceeb7e10c81a12e8d75889996a5c9da8", size = 4424368, upload-time = "2026-03-31T22:40:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/e4/76/ab597bae87e1f06d18d3ecb8ed7f0d3c9a37037fc32ce76233d369273c64/hf_xet-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0392c79b7cf48418cd61478c1a925246cf10639f4cd9d94368d8ca1e8df9ea07", size = 3672280, upload-time = "2026-03-31T22:40:16.401Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/2e462d34e23a09a74d73785dbed71cc5dbad82a72eee2ad60a72a554155d/hf_xet-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:681c92a07796325778a79d76c67011764ecc9042a8c3579332b61b63ae512075", size = 3528945, upload-time = "2026-03-31T22:40:14.995Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9f/9c23e4a447b8f83120798f9279d0297a4d1360bdbf59ef49ebec78fe2545/hf_xet-1.4.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d0da85329eaf196e03e90b84c2d0aca53bd4573d097a75f99609e80775f98025", size = 3805048, upload-time = "2026-03-31T22:39:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f8/7aacb8e5f4a7899d39c787b5984e912e6c18b11be136ef13947d7a66d265/hf_xet-1.4.3-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e23717ce4186b265f69afa66e6f0069fe7efbf331546f5c313d00e123dc84583", size = 3562178, upload-time = "2026-03-31T22:39:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc360b70c815bf340ed56c7b8c63aacf11762a4b099b2fe2c9bd6d6068668c08", size = 4212320, upload-time = "2026-03-31T22:39:42.922Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/46d493db155d2ee2801b71fb1b0fd67696359047fdd8caee2c914cc50c79/hf_xet-1.4.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:39f2d2e9654cd9b4319885733993807aab6de9dfbd34c42f0b78338d6617421f", size = 3991546, upload-time = "2026-03-31T22:39:41.335Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f5/067363e1c96c6b17256910830d1b54099d06287e10f4ec6ec4e7e08371fc/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:49ad8a8cead2b56051aa84d7fce3e1335efe68df3cf6c058f22a65513885baac", size = 4193200, upload-time = "2026-03-31T22:40:01.936Z" }, + { url = "https://files.pythonhosted.org/packages/42/4b/53951592882d9c23080c7644542fda34a3813104e9e11fa1a7d82d419cb8/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7716d62015477a70ea272d2d68cd7cad140f61c52ee452e133e139abfe2c17ba", size = 4429392, upload-time = "2026-03-31T22:40:03.492Z" }, + { url = "https://files.pythonhosted.org/packages/8a/21/75a6c175b4e79662ad8e62f46a40ce341d8d6b206b06b4320d07d55b188c/hf_xet-1.4.3-cp37-abi3-win_amd64.whl", hash = "sha256:6b591fcad34e272a5b02607485e4f2a1334aebf1bc6d16ce8eb1eb8978ac2021", size = 3677359, upload-time = "2026-03-31T22:40:13.619Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload-time = "2026-03-31T22:40:12.152Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "socksio" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/52/1b54cb569509c725a32c1315261ac9fd0e6b91bbbf74d86fca10d3376164/huggingface_hub-1.12.0.tar.gz", hash = "sha256:7c3fe85e24b652334e5d456d7a812cd9a071e75630fac4365d9165ab5e4a34b6", size = 763091, upload-time = "2026-04-24T13:32:08.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/2b/ef03ddb96bd1123503c2bd6932001020292deea649e9bf4caa2cb65a85bf/huggingface_hub-1.12.0-py3-none-any.whl", hash = "sha256:d74939969585ee35748bd66de09baf84099d461bda7287cd9043bfb99b0e424d", size = 646806, upload-time = "2026-04-24T13:32:06.717Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "lazy-loader" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6b/c875b30a1ba490860c93da4cabf479e03f584eba06fe5963f6f6644653d8/lazy_loader-0.4.tar.gz", hash = "sha256:47c75182589b91a4e1a85a136c074285a5ad4d9f39c63e0d7fb76391c4574cd1", size = 15431, upload-time = "2024-04-05T13:03:12.261Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc", size = 12097, upload-time = "2024-04-05T13:03:10.514Z" }, +] + +[[package]] +name = "librosa" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "audioread" }, + { name = "decorator" }, + { name = "joblib" }, + { name = "lazy-loader" }, + { name = "msgpack" }, + { name = "numba" }, + { name = "numpy" }, + { name = "pooch" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "soundfile" }, + { name = "soxr" }, + { name = "standard-aifc" }, + { name = "standard-sunau" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/36/360b5aafa0238e29758729e9486c6ed92a6f37fa403b7875e06c115cdf4a/librosa-0.11.0.tar.gz", hash = "sha256:f5ed951ca189b375bbe2e33b2abd7e040ceeee302b9bbaeeffdfddb8d0ace908", size = 327001, upload-time = "2025-03-11T15:09:54.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/ba/c63c5786dfee4c3417094c4b00966e61e4a63efecee22cb7b4c0387dda83/librosa-0.11.0-py3-none-any.whl", hash = "sha256:0b6415c4fd68bff4c29288abe67c6d80b587e0e1e2cfb0aad23e4559504a7fa1", size = 260749, upload-time = "2025-03-11T15:09:52.982Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/cd/08ae687ba099c7e3d21fe2ea536500563ef1943c5105bf6ab4ee3829f68e/llvmlite-0.46.0.tar.gz", hash = "sha256:227c9fd6d09dce2783c18b754b7cd9d9b3b3515210c46acc2d3c5badd9870ceb", size = 193456, upload-time = "2025-12-08T18:15:36.295Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/ae/af0ffb724814cc2ea64445acad05f71cff5f799bb7efb22e47ee99340dbc/llvmlite-0.46.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:d252edfb9f4ac1fcf20652258e3f102b26b03eef738dc8a6ffdab7d7d341d547", size = 37232768, upload-time = "2025-12-08T18:15:25.055Z" }, + { url = "https://files.pythonhosted.org/packages/c9/19/5018e5352019be753b7b07f7759cdabb69ca5779fea2494be8839270df4c/llvmlite-0.46.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379fdd1c59badeff8982cb47e4694a6143bec3bb49aa10a466e095410522064d", size = 56275173, upload-time = "2025-12-08T18:15:28.109Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c9/d57877759d707e84c082163c543853245f91b70c804115a5010532890f18/llvmlite-0.46.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e8cbfff7f6db0fa2c771ad24154e2a7e457c2444d7673e6de06b8b698c3b269", size = 55128628, upload-time = "2025-12-08T18:15:31.098Z" }, + { url = "https://files.pythonhosted.org/packages/30/a8/e61a8c2b3cc7a597073d9cde1fcbb567e9d827f1db30c93cf80422eac70d/llvmlite-0.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:7821eda3ec1f18050f981819756631d60b6d7ab1a6cf806d9efefbe3f4082d61", size = 39153056, upload-time = "2025-12-08T18:15:33.938Z" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numba" +version = "0.63.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/60/0145d479b2209bd8fdae5f44201eceb8ce5a23e0ed54c71f57db24618665/numba-0.63.1.tar.gz", hash = "sha256:b320aa675d0e3b17b40364935ea52a7b1c670c9037c39cf92c49502a75902f4b", size = 2761666, upload-time = "2025-12-10T02:57:39.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/2f/53be2aa8a55ee2608ebe1231789cbb217f6ece7f5e1c685d2f0752e95a5b/numba-0.63.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f180883e5508940cc83de8a8bea37fc6dd20fbe4e5558d4659b8b9bef5ff4731", size = 2681153, upload-time = "2025-12-10T02:57:32.016Z" }, + { url = "https://files.pythonhosted.org/packages/13/91/53e59c86759a0648282368d42ba732c29524a745fd555ed1fb1df83febbe/numba-0.63.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0938764afa82a47c0e895637a6c55547a42c9e1d35cac42285b1fa60a8b02bb", size = 3778718, upload-time = "2025-12-10T02:57:33.764Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0c/2be19eba50b0b7636f6d1f69dfb2825530537708a234ba1ff34afc640138/numba-0.63.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f90a929fa5094e062d4e0368ede1f4497d5e40f800e80aa5222c4734236a2894", size = 3478712, upload-time = "2025-12-10T02:57:35.518Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/4d0c9e756732577a52211f31da13a3d943d185f7fb90723f56d79c696caa/numba-0.63.1-cp314-cp314-win_amd64.whl", hash = "sha256:8d6d5ce85f572ed4e1a135dbb8c0114538f9dd0e3657eeb0bb64ab204cbe2a8f", size = 2752161, upload-time = "2025-12-10T02:57:37.12Z" }, +] + +[[package]] +name = "numpy" +version = "2.3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950, upload-time = "2025-11-16T22:52:42.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/97/1a914559c19e32d6b2e233cf9a6a114e67c856d35b1d6babca571a3e880f/numpy-2.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:bf06bc2af43fa8d32d30fae16ad965663e966b1a3202ed407b84c989c3221e82", size = 16735706, upload-time = "2025-11-16T22:51:19.558Z" }, + { url = "https://files.pythonhosted.org/packages/57/d4/51233b1c1b13ecd796311216ae417796b88b0616cfd8a33ae4536330748a/numpy-2.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:052e8c42e0c49d2575621c158934920524f6c5da05a1d3b9bab5d8e259e045f0", size = 12264507, upload-time = "2025-11-16T22:51:22.492Z" }, + { url = "https://files.pythonhosted.org/packages/45/98/2fe46c5c2675b8306d0b4a3ec3494273e93e1226a490f766e84298576956/numpy-2.3.5-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:1ed1ec893cff7040a02c8aa1c8611b94d395590d553f6b53629a4461dc7f7b63", size = 5093049, upload-time = "2025-11-16T22:51:25.171Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0e/0698378989bb0ac5f1660c81c78ab1fe5476c1a521ca9ee9d0710ce54099/numpy-2.3.5-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2dcd0808a421a482a080f89859a18beb0b3d1e905b81e617a188bd80422d62e9", size = 6626603, upload-time = "2025-11-16T22:51:27Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a6/9ca0eecc489640615642a6cbc0ca9e10df70df38c4d43f5a928ff18d8827/numpy-2.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727fd05b57df37dc0bcf1a27767a3d9a78cbbc92822445f32cc3436ba797337b", size = 14262696, upload-time = "2025-11-16T22:51:29.402Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f6/07ec185b90ec9d7217a00eeeed7383b73d7e709dae2a9a021b051542a708/numpy-2.3.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fffe29a1ef00883599d1dc2c51aa2e5d80afe49523c261a74933df395c15c520", size = 16597350, upload-time = "2025-11-16T22:51:32.167Z" }, + { url = "https://files.pythonhosted.org/packages/75/37/164071d1dde6a1a84c9b8e5b414fa127981bad47adf3a6b7e23917e52190/numpy-2.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f7f0e05112916223d3f438f293abf0727e1181b5983f413dfa2fefc4098245c", size = 16040190, upload-time = "2025-11-16T22:51:35.403Z" }, + { url = "https://files.pythonhosted.org/packages/08/3c/f18b82a406b04859eb026d204e4e1773eb41c5be58410f41ffa511d114ae/numpy-2.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2e2eb32ddb9ccb817d620ac1d8dae7c3f641c1e5f55f531a33e8ab97960a75b8", size = 18536749, upload-time = "2025-11-16T22:51:39.698Z" }, + { url = "https://files.pythonhosted.org/packages/40/79/f82f572bf44cf0023a2fe8588768e23e1592585020d638999f15158609e1/numpy-2.3.5-cp314-cp314-win32.whl", hash = "sha256:66f85ce62c70b843bab1fb14a05d5737741e74e28c7b8b5a064de10142fad248", size = 6335432, upload-time = "2025-11-16T22:51:42.476Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2e/235b4d96619931192c91660805e5e49242389742a7a82c27665021db690c/numpy-2.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:e6a0bc88393d65807d751a614207b7129a310ca4fe76a74e5c7da5fa5671417e", size = 12919388, upload-time = "2025-11-16T22:51:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/07/2b/29fd75ce45d22a39c61aad74f3d718e7ab67ccf839ca8b60866054eb15f8/numpy-2.3.5-cp314-cp314-win_arm64.whl", hash = "sha256:aeffcab3d4b43712bb7a60b65f6044d444e75e563ff6180af8f98dd4b905dfd2", size = 10476651, upload-time = "2025-11-16T22:51:47.749Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f6a721234ebd4d87084cfa68d081bcba2f5cfe1974f7de4e0e8b9b2a2ba1/numpy-2.3.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17531366a2e3a9e30762c000f2c43a9aaa05728712e25c11ce1dbe700c53ad41", size = 16834503, upload-time = "2025-11-16T22:51:50.443Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1c/baf7ffdc3af9c356e1c135e57ab7cf8d247931b9554f55c467efe2c69eff/numpy-2.3.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d21644de1b609825ede2f48be98dfde4656aefc713654eeee280e37cadc4e0ad", size = 12381612, upload-time = "2025-11-16T22:51:53.609Z" }, + { url = "https://files.pythonhosted.org/packages/74/91/f7f0295151407ddc9ba34e699013c32c3c91944f9b35fcf9281163dc1468/numpy-2.3.5-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c804e3a5aba5460c73955c955bdbd5c08c354954e9270a2c1565f62e866bdc39", size = 5210042, upload-time = "2025-11-16T22:51:56.213Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3b/78aebf345104ec50dd50a4d06ddeb46a9ff5261c33bcc58b1c4f12f85ec2/numpy-2.3.5-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:cc0a57f895b96ec78969c34f682c602bf8da1a0270b09bc65673df2e7638ec20", size = 6724502, upload-time = "2025-11-16T22:51:58.584Z" }, + { url = "https://files.pythonhosted.org/packages/02/c6/7c34b528740512e57ef1b7c8337ab0b4f0bddf34c723b8996c675bc2bc91/numpy-2.3.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:900218e456384ea676e24ea6a0417f030a3b07306d29d7ad843957b40a9d8d52", size = 14308962, upload-time = "2025-11-16T22:52:01.698Z" }, + { url = "https://files.pythonhosted.org/packages/80/35/09d433c5262bc32d725bafc619e095b6a6651caf94027a03da624146f655/numpy-2.3.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09a1bea522b25109bf8e6f3027bd810f7c1085c64a0c7ce050c1676ad0ba010b", size = 16655054, upload-time = "2025-11-16T22:52:04.267Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ab/6a7b259703c09a88804fa2430b43d6457b692378f6b74b356155283566ac/numpy-2.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04822c00b5fd0323c8166d66c701dc31b7fbd252c100acd708c48f763968d6a3", size = 16091613, upload-time = "2025-11-16T22:52:08.651Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/330da2071e8771e60d1038166ff9d73f29da37b01ec3eb43cb1427464e10/numpy-2.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d6889ec4ec662a1a37eb4b4fb26b6100841804dac55bd9df579e326cdc146227", size = 18591147, upload-time = "2025-11-16T22:52:11.453Z" }, + { url = "https://files.pythonhosted.org/packages/51/41/851c4b4082402d9ea860c3626db5d5df47164a712cb23b54be028b184c1c/numpy-2.3.5-cp314-cp314t-win32.whl", hash = "sha256:93eebbcf1aafdf7e2ddd44c2923e2672e1010bddc014138b229e49725b4d6be5", size = 6479806, upload-time = "2025-11-16T22:52:14.641Z" }, + { url = "https://files.pythonhosted.org/packages/90/30/d48bde1dfd93332fa557cff1972fbc039e055a52021fbef4c2c4b1eefd17/numpy-2.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c8a9958e88b65c3b27e22ca2a076311636850b612d6bbfb76e8d156aacde2aaf", size = 13105760, upload-time = "2025-11-16T22:52:17.975Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/4b5eb0b3e888d86aee4d198c23acec7d214baaf17ea93c1adec94c9518b9/numpy-2.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:6203fdf9f3dc5bdaed7319ad8698e685c7a3be10819f41d32a0723e611733b42", size = 10545459, upload-time = "2025-11-16T22:52:20.55Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.0.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/a5/fce49e2ae977e0ccc084e5adafceb4f0ac0c8333cb6863501618a7277f67/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c86fc7f7ae36d7528288c5d88098edcb7b02c633d262e7ddbb86b0ad91be5df2", size = 542851226, upload-time = "2025-10-09T08:59:04.818Z" }, + { url = "https://files.pythonhosted.org/packages/e7/44/423ac00af4dd95a5aeb27207e2c0d9b7118702149bf4704c3ddb55bb7429/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:ee8722c1f0145ab246bccb9e452153b5e0515fd094c3678df50b2a0888b8b171", size = 423133236, upload-time = "2025-10-09T08:59:32.536Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.20.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.29.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" }, + { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + +[[package]] +name = "nvidia-riva-client" +version = "2.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "grpcio-tools" }, + { name = "protobuf" }, + { name = "websockets" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/53/0988b79ee818cb2b8792abeda5a75156946b64da7a2ef2719b9d2068a34f/nvidia_riva_client-2.26.0-py3-none-any.whl", hash = "sha256:16ffc98266fa7be7261e0675de6b7028e7f973c2ac3dfd679668148ff497cc0c", size = 57539, upload-time = "2026-05-28T06:11:43.205Z" }, +] + +[[package]] +name = "openai" +version = "2.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/f5/7c7cb955305cb41f7f3c5fd7e0e38bf6bbf2658468863d4b7b868a5cb8df/openai-2.44.0.tar.gz", hash = "sha256:68a5a5ffad82b8ff7d451c437529fb64f7c3b8123aaf0c021966a882d9e3947d", size = 988753, upload-time = "2026-06-24T20:56:02.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/f4/561ed79fd94876160018a5e75254cfcb9b0e62d4dded9dcb20072e86d623/openai-2.44.0-py3-none-any.whl", hash = "sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad", size = 1366216, upload-time = "2026-06-24T20:55:58.882Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pooch" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "platformdirs" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/43/85ef45e8b36c6a48546af7b266592dc32d7f67837a6514d111bced6d7d75/pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed", size = 61788, upload-time = "2026-01-30T19:15:09.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/35/2fee58b1316a73e025728583d3b1447218a97e621933fc776fb8c0f2ebdd/pydantic_extra_types-2.11.0.tar.gz", hash = "sha256:4e9991959d045b75feb775683437a97991d02c138e00b59176571db9ce634f0e", size = 157226, upload-time = "2025-12-31T16:18:27.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/17/fabd56da47096d240dd45ba627bead0333b0cf0ee8ada9bec579287dadf3/pydantic_extra_types-2.11.0-py3-none-any.whl", hash = "sha256:84b864d250a0fc62535b7ec591e36f2c5b4d1325fa0017eb8cda9aeb63b374a6", size = 74296, upload-time = "2025-12-31T16:18:26.38Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "python-telegram-bot" +version = "22.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpcore" }, + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/77/153517bb1ac1bba670c6fb1dbf09e1fd0730494b1705934e715391413a0d/python_telegram_bot-22.8.tar.gz", hash = "sha256:f9d3847fcb23ee603477e442800b33bb4adf851a73e0619d2050be879decf1ef", size = 1551700, upload-time = "2026-06-12T08:10:29.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/7c/ed7d4dd94280bd434173cae9f7a7aedaaab9af128ae4f494423a5687c820/python_telegram_bot-22.8-py3-none-any.whl", hash = "sha256:42373918097f1b837cc4e717d588c19ea79651497ec712bb5b0c76e5e63c50e1", size = 769397, upload-time = "2026-06-12T08:10:27.066Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.1.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" }, + { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" }, + { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" }, + { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" }, + { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" }, + { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" }, + { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" }, + { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" }, + { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" }, + { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" }, + { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" }, + { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" }, + { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" }, + { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.19.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/c9/4bbf4bfee195ed1b7d7a6733cc523ca61dbfb4a3e3c12ea090aaffd97597/rich_toolkit-0.19.4.tar.gz", hash = "sha256:52e23d56f9dc30d1343eb3b3f6f18764c313fbfea24e52e6a1d6069bec9c18eb", size = 193951, upload-time = "2026-02-12T10:08:15.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/31/97d39719def09c134385bfcfbedfed255168b571e7beb3ad7765aae660ca/rich_toolkit-0.19.4-py3-none-any.whl", hash = "sha256:34ac344de8862801644be8b703e26becf44b047e687f208d7829e8f7cfc311d6", size = 32757, upload-time = "2026-02-12T10:08:15.037Z" }, +] + +[[package]] +name = "rignore" +version = "0.7.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" }, + { url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" }, + { url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" }, + { url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" }, + { url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" }, + { url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" }, + { url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" }, + { url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "safetensors" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/2d/51006cd369b8e7879e1c630999a19d1fbf6f8b5ed3e33374f29dc87e53b3/scipy-1.17.0-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:c17514d11b78be8f7e6331b983a65a7f5ca1fd037b95e27b280921fe5606286a", size = 31346803, upload-time = "2026-01-10T21:28:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2e/2349458c3ce445f53a6c93d4386b1c4c5c0c540917304c01222ff95ff317/scipy-1.17.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:4e00562e519c09da34c31685f6acc3aa384d4d50604db0f245c14e1b4488bfa2", size = 27967182, upload-time = "2026-01-10T21:29:04.107Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7c/df525fbfa77b878d1cfe625249529514dc02f4fd5f45f0f6295676a76528/scipy-1.17.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f7df7941d71314e60a481e02d5ebcb3f0185b8d799c70d03d8258f6c80f3d467", size = 20139125, upload-time = "2026-01-10T21:29:10.179Z" }, + { url = "https://files.pythonhosted.org/packages/33/11/fcf9d43a7ed1234d31765ec643b0515a85a30b58eddccc5d5a4d12b5f194/scipy-1.17.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:aabf057c632798832f071a8dde013c2e26284043934f53b00489f1773b33527e", size = 22443554, upload-time = "2026-01-10T21:29:15.888Z" }, + { url = "https://files.pythonhosted.org/packages/80/5c/ea5d239cda2dd3d31399424967a24d556cf409fbea7b5b21412b0fd0a44f/scipy-1.17.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a38c3337e00be6fd8a95b4ed66b5d988bac4ec888fd922c2ea9fe5fb1603dd67", size = 32757834, upload-time = "2026-01-10T21:29:23.406Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7e/8c917cc573310e5dc91cbeead76f1b600d3fb17cf0969db02c9cf92e3cfa/scipy-1.17.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00fb5f8ec8398ad90215008d8b6009c9db9fa924fd4c7d6be307c6f945f9cd73", size = 34995775, upload-time = "2026-01-10T21:29:31.915Z" }, + { url = "https://files.pythonhosted.org/packages/c5/43/176c0c3c07b3f7df324e7cdd933d3e2c4898ca202b090bd5ba122f9fe270/scipy-1.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f2a4942b0f5f7c23c7cd641a0ca1955e2ae83dedcff537e3a0259096635e186b", size = 34841240, upload-time = "2026-01-10T21:29:39.995Z" }, + { url = "https://files.pythonhosted.org/packages/44/8c/d1f5f4b491160592e7f084d997de53a8e896a3ac01cd07e59f43ca222744/scipy-1.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf133ced83889583156566d2bdf7a07ff89228fe0c0cb727f777de92092ec6b", size = 37394463, upload-time = "2026-01-10T21:29:48.723Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ec/42a6657f8d2d087e750e9a5dde0b481fd135657f09eaf1cf5688bb23c338/scipy-1.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3625c631a7acd7cfd929e4e31d2582cf00f42fcf06011f59281271746d77e061", size = 37053015, upload-time = "2026-01-10T21:30:51.418Z" }, + { url = "https://files.pythonhosted.org/packages/27/58/6b89a6afd132787d89a362d443a7bddd511b8f41336a1ae47f9e4f000dc4/scipy-1.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:9244608d27eafe02b20558523ba57f15c689357c85bdcfe920b1828750aa26eb", size = 24951312, upload-time = "2026-01-10T21:30:56.771Z" }, + { url = "https://files.pythonhosted.org/packages/e9/01/f58916b9d9ae0112b86d7c3b10b9e685625ce6e8248df139d0fcb17f7397/scipy-1.17.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:2b531f57e09c946f56ad0b4a3b2abee778789097871fc541e267d2eca081cff1", size = 31706502, upload-time = "2026-01-10T21:29:56.326Z" }, + { url = "https://files.pythonhosted.org/packages/59/8e/2912a87f94a7d1f8b38aabc0faf74b82d3b6c9e22be991c49979f0eceed8/scipy-1.17.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:13e861634a2c480bd237deb69333ac79ea1941b94568d4b0efa5db5e263d4fd1", size = 28380854, upload-time = "2026-01-10T21:30:01.554Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1c/874137a52dddab7d5d595c1887089a2125d27d0601fce8c0026a24a92a0b/scipy-1.17.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:eb2651271135154aa24f6481cbae5cc8af1f0dd46e6533fb7b56aa9727b6a232", size = 20552752, upload-time = "2026-01-10T21:30:05.93Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/7518d171cb735f6400f4576cf70f756d5b419a07fe1867da34e2c2c9c11b/scipy-1.17.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:c5e8647f60679790c2f5c76be17e2e9247dc6b98ad0d3b065861e082c56e078d", size = 22803972, upload-time = "2026-01-10T21:30:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/7c/74/3498563a2c619e8a3ebb4d75457486c249b19b5b04a30600dfd9af06bea5/scipy-1.17.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fb10d17e649e1446410895639f3385fd2bf4c3c7dfc9bea937bddcbc3d7b9ba", size = 32829770, upload-time = "2026-01-10T21:30:16.359Z" }, + { url = "https://files.pythonhosted.org/packages/48/d1/7b50cedd8c6c9d6f706b4b36fa8544d829c712a75e370f763b318e9638c1/scipy-1.17.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8547e7c57f932e7354a2319fab613981cde910631979f74c9b542bb167a8b9db", size = 35051093, upload-time = "2026-01-10T21:30:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/e2/82/a2d684dfddb87ba1b3ea325df7c3293496ee9accb3a19abe9429bce94755/scipy-1.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33af70d040e8af9d5e7a38b5ed3b772adddd281e3062ff23fec49e49681c38cf", size = 34909905, upload-time = "2026-01-10T21:30:28.704Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5e/e565bd73991d42023eb82bb99e51c5b3d9e2c588ca9d4b3e2cc1d3ca62a6/scipy-1.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb55bb97d00f8b7ab95cb64f873eb0bf54d9446264d9f3609130381233483f", size = 37457743, upload-time = "2026-01-10T21:30:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/58/a8/a66a75c3d8f1fb2b83f66007d6455a06a6f6cf5618c3dc35bc9b69dd096e/scipy-1.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1ff269abf702f6c7e67a4b7aad981d42871a11b9dd83c58d2d2ea624efbd1088", size = 37098574, upload-time = "2026-01-10T21:30:40.782Z" }, + { url = "https://files.pythonhosted.org/packages/56/a5/df8f46ef7da168f1bc52cd86e09a9de5c6f19cc1da04454d51b7d4f43408/scipy-1.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:031121914e295d9791319a1875444d55079885bbae5bdc9c5e0f2ee5f09d34ff", size = 25246266, upload-time = "2026-01-10T21:30:45.923Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.52.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/eb/1b497650eb564701f9a7b8a95c51b2abe9347ed2c0b290ba78f027ebe4ea/sentry_sdk-2.52.0.tar.gz", hash = "sha256:fa0bec872cfec0302970b2996825723d67390cdd5f0229fb9efed93bd5384899", size = 410273, upload-time = "2026-02-04T15:03:54.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/63/2c6daf59d86b1c30600bff679d039f57fd1932af82c43c0bde1cbc55e8d4/sentry_sdk-2.52.0-py2.py3-none-any.whl", hash = "sha256:931c8f86169fc6f2752cb5c4e6480f0d516112e78750c312e081ababecbaf2ed", size = 435547, upload-time = "2026-02-04T15:03:51.567Z" }, +] + +[[package]] +name = "setuptools" +version = "78.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9c/42314ee079a3e9c24b27515f9fbc7a3c1d29992c33451779011c74488375/setuptools-78.1.1.tar.gz", hash = "sha256:fcc17fd9cd898242f6b4adfaca46137a9edef687f43e6f78469692a5e70d851d", size = 1368163, upload-time = "2025-04-19T18:23:36.68Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/99/158ad0609729111163fc1f674a5a42f2605371a4cf036d0441070e2f7455/setuptools-78.1.1-py3-none-any.whl", hash = "sha256:c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561", size = 1256462, upload-time = "2025-04-19T18:23:34.525Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + +[[package]] +name = "soundfile" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/41/9b873a8c055582859b239be17902a85339bec6a30ad162f98c9b0288a2cc/soundfile-0.13.1.tar.gz", hash = "sha256:b2c68dab1e30297317080a5b43df57e302584c49e2942defdde0acccc53f0e5b", size = 46156, upload-time = "2025-01-25T09:17:04.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/28/e2a36573ccbcf3d57c00626a21fe51989380636e821b341d36ccca0c1c3a/soundfile-0.13.1-py2.py3-none-any.whl", hash = "sha256:a23c717560da2cf4c7b5ae1142514e0fd82d6bbd9dfc93a50423447142f2c445", size = 25751, upload-time = "2025-01-25T09:16:44.235Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/73e97a5b3cc46bba7ff8650a1504348fa1863a6f9d57d7001c6b67c5f20e/soundfile-0.13.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:82dc664d19831933fe59adad199bf3945ad06d84bc111a5b4c0d3089a5b9ec33", size = 1142250, upload-time = "2025-01-25T09:16:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e5/58fd1a8d7b26fc113af244f966ee3aecf03cb9293cb935daaddc1e455e18/soundfile-0.13.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:743f12c12c4054921e15736c6be09ac26b3b3d603aef6fd69f9dde68748f2593", size = 1101406, upload-time = "2025-01-25T09:16:49.662Z" }, + { url = "https://files.pythonhosted.org/packages/58/ae/c0e4a53d77cf6e9a04179535766b3321b0b9ced5f70522e4caf9329f0046/soundfile-0.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9c9e855f5a4d06ce4213f31918653ab7de0c5a8d8107cd2427e44b42df547deb", size = 1235729, upload-time = "2025-01-25T09:16:53.018Z" }, + { url = "https://files.pythonhosted.org/packages/57/5e/70bdd9579b35003a489fc850b5047beeda26328053ebadc1fb60f320f7db/soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:03267c4e493315294834a0870f31dbb3b28a95561b80b134f0bd3cf2d5f0e618", size = 1313646, upload-time = "2025-01-25T09:16:54.872Z" }, + { url = "https://files.pythonhosted.org/packages/fe/df/8c11dc4dfceda14e3003bb81a0d0edcaaf0796dd7b4f826ea3e532146bba/soundfile-0.13.1-py2.py3-none-win32.whl", hash = "sha256:c734564fab7c5ddf8e9be5bf70bab68042cd17e9c214c06e365e20d64f9a69d5", size = 899881, upload-time = "2025-01-25T09:16:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162, upload-time = "2025-01-25T09:16:59.573Z" }, +] + +[[package]] +name = "soxr" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/7e/f4b461944662ad75036df65277d6130f9411002bfb79e9df7dff40a31db9/soxr-1.0.0.tar.gz", hash = "sha256:e07ee6c1d659bc6957034f4800c60cb8b98de798823e34d2a2bba1caa85a4509", size = 171415, upload-time = "2025-09-07T13:22:21.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c7/f92b81f1a151c13afb114f57799b86da9330bec844ea5a0d3fe6a8732678/soxr-1.0.0-cp312-abi3-macosx_10_14_x86_64.whl", hash = "sha256:abecf4e39017f3fadb5e051637c272ae5778d838e5c3926a35db36a53e3a607f", size = 205508, upload-time = "2025-09-07T13:22:01.252Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1d/c945fea9d83ea1f2be9d116b3674dbaef26ed090374a77c394b31e3b083b/soxr-1.0.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:e973d487ee46aa8023ca00a139db6e09af053a37a032fe22f9ff0cc2e19c94b4", size = 163568, upload-time = "2025-09-07T13:22:03.558Z" }, + { url = "https://files.pythonhosted.org/packages/b5/80/10640970998a1d2199bef6c4d92205f36968cddaf3e4d0e9fe35ddd405bd/soxr-1.0.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ce273cca101aff3d8c387db5a5a41001ba76ef1837883438d3c652507a9ccc", size = 204707, upload-time = "2025-09-07T13:22:05.125Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/2726603c13c2126cb8ded9e57381b7377f4f0df6ba4408e1af5ddbfdc3dd/soxr-1.0.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f2a69686f2856d37823bbb7b78c3d44904f311fe70ba49b893af11d6b6047b", size = 238032, upload-time = "2025-09-07T13:22:06.428Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/530252227f4d0721a5524a936336485dfb429bb206a66baf8e470384f4a2/soxr-1.0.0-cp312-abi3-win_amd64.whl", hash = "sha256:2a3b77b115ae7c478eecdbd060ed4f61beda542dfb70639177ac263aceda42a2", size = 172070, upload-time = "2025-09-07T13:22:07.62Z" }, + { url = "https://files.pythonhosted.org/packages/99/77/d3b3c25b4f1b1aa4a73f669355edcaee7a52179d0c50407697200a0e55b9/soxr-1.0.0-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:392a5c70c04eb939c9c176bd6f654dec9a0eaa9ba33d8f1024ed63cf68cdba0a", size = 209509, upload-time = "2025-09-07T13:22:08.773Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ee/3ca73e18781bb2aff92b809f1c17c356dfb9a1870652004bd432e79afbfa/soxr-1.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fdc41a1027ba46777186f26a8fba7893be913383414135577522da2fcc684490", size = 167690, upload-time = "2025-09-07T13:22:10.259Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f0/eea8b5f587a2531657dc5081d2543a5a845f271a3bea1c0fdee5cebde021/soxr-1.0.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:449acd1dfaf10f0ce6dfd75c7e2ef984890df94008765a6742dafb42061c1a24", size = 209541, upload-time = "2025-09-07T13:22:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/64/59/2430a48c705565eb09e78346950b586f253a11bd5313426ced3ecd9b0feb/soxr-1.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:38b35c99e408b8f440c9376a5e1dd48014857cd977c117bdaa4304865ae0edd0", size = 243025, upload-time = "2025-09-07T13:22:12.877Z" }, + { url = "https://files.pythonhosted.org/packages/3c/1b/f84a2570a74094e921bbad5450b2a22a85d58585916e131d9b98029c3e69/soxr-1.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a39b519acca2364aa726b24a6fd55acf29e4c8909102e0b858c23013c38328e5", size = 184850, upload-time = "2025-09-07T13:22:14.068Z" }, +] + +[[package]] +name = "standard-aifc" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "audioop-lts" }, + { name = "standard-chunk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/52/5fbb203394cc852334d1575cc020f6bcec768d2265355984dfd361968f36/standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66", size = 10492, upload-time = "2024-10-30T16:01:07.071Z" }, +] + +[[package]] +name = "standard-chunk" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/06/ce1bb165c1f111c7d23a1ad17204d67224baa69725bb6857a264db61beaf/standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654", size = 4672, upload-time = "2024-10-30T16:18:28.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/90/a5c1084d87767d787a6caba615aa50dc587229646308d9420c960cb5e4c0/standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c", size = 4944, upload-time = "2024-10-30T16:18:26.694Z" }, +] + +[[package]] +name = "standard-sunau" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "audioop-lts" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/e3/ce8d38cb2d70e05ffeddc28bb09bad77cfef979eb0a299c9117f7ed4e6a9/standard_sunau-3.13.0.tar.gz", hash = "sha256:b319a1ac95a09a2378a8442f403c66f4fd4b36616d6df6ae82b8e536ee790908", size = 9368, upload-time = "2024-10-30T16:01:41.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/ae/e3707f6c1bc6f7aa0df600ba8075bfb8a19252140cd595335be60e25f9ee/standard_sunau-3.13.0-py3-none-any.whl", hash = "sha256:53af624a9529c41062f4c2fd33837f297f3baa196b0cfceffea6555654602622", size = 7364, upload-time = "2024-10-30T16:01:28.003Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471", size = 1034977, upload-time = "2026-05-15T04:51:00.957Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd", size = 983635, upload-time = "2026-05-15T04:51:02.629Z" }, + { url = "https://files.pythonhosted.org/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881", size = 1116036, upload-time = "2026-05-15T04:51:04.082Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24", size = 1135544, upload-time = "2026-05-15T04:51:05.229Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273", size = 1182217, upload-time = "2026-05-15T04:51:06.517Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51", size = 1239404, upload-time = "2026-05-15T04:51:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58", size = 918686, upload-time = "2026-05-15T04:51:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b", size = 1034454, upload-time = "2026-05-15T04:51:10.035Z" }, + { url = "https://files.pythonhosted.org/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448", size = 982976, upload-time = "2026-05-15T04:51:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a", size = 1115526, upload-time = "2026-05-15T04:51:12.608Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad", size = 1136466, upload-time = "2026-05-15T04:51:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e", size = 1181863, upload-time = "2026-05-15T04:51:15.025Z" }, + { url = "https://files.pythonhosted.org/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424", size = 1239218, upload-time = "2026-05-15T04:51:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, +] + +[[package]] +name = "torch" +version = "2.12.1+cu130" +source = { registry = "https://download.pytorch.org/whl/cu130" } +dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d6770341450d042a2998be19b5f7e431ab27281d356148a6eba659f5afff7c3e", upload-time = "2026-06-18T02:44:24Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c84c5988b3e416669ed3790d772479af5934d1788119288d8bddd9c4cb207285", upload-time = "2026-06-18T02:44:58Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp314-cp314-win_amd64.whl", hash = "sha256:d1cd8a4fd0556b2604db5447d9298323b8fba1ba67501fb3d8b22485c764a3a6", upload-time = "2026-06-18T02:46:20Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3d9cbeffed603075484270c34344e9506d86850af734d1e2eee13a8ba4bd690c", upload-time = "2026-06-18T02:47:40Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0b4a8bdc9a89d1109bf4056d56e1c3ed05966273056d983a91f1cf597c25bea6", upload-time = "2026-06-18T02:48:07Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.12.1%2Bcu130-cp314-cp314t-win_amd64.whl", hash = "sha256:046caae1457ec8a88256536958538dffb173c419320e3478f5b8d676038ec0d8", upload-time = "2026-06-18T02:49:24Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "transformers" +version = "5.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/e1/720ff7ff666b04279fea5bb7ac3ef8675e98f0ddbc1b8cb8bc9f3889d62e/transformers-5.13.0.tar.gz", hash = "sha256:940c1428e42a4238f9ccf0cd41e63c590701aa63c19fd2ce3d7d602222d68495", size = 9195801, upload-time = "2026-07-03T16:05:39.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/3a/d99704c5effe10c6339c98cb236259161103e159bb99a78468b6729572ec/transformers-5.13.0-py3-none-any.whl", hash = "sha256:8adbc1d20bd5463cd6876b2eb7cb31971e1065788e7dc6bc12bab597a7c504b7", size = 11503730, upload-time = "2026-07-03T16:05:35.569Z" }, +] + +[[package]] +name = "triton" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/71/e01aa7ad573883ed9456f130226babdec70b005e098c4d6226a6238e761b/triton-3.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4ea396a06171f1f1f58cbd39c70b09294398f7dd7c620939bab54ad6f934fa", size = 184705764, upload-time = "2026-06-17T20:03:59.064Z" }, + { url = "https://files.pythonhosted.org/packages/a4/09/5683146fda6a2b569deb78ccfd8fbfea8bfe55f726b081c0a6bb18dd6f28/triton-3.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2020153b08280415ec0da6607834e79166442147e78e144df06b508c75b186d2", size = 197729537, upload-time = "2026-06-17T19:53:35.516Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/448220c3092019f9fdfab39ec47985968181d67da34b44f6a7f6280a5cbb/triton-3.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c58e4c61f0c73b5dba3b5d19b4a7093c32f90dc18b2a7f121a7c16ccd31107b7", size = 184814760, upload-time = "2026-06-17T20:04:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ac/229b7d4589d2e5937310e72c6d46e89599d16a4a12b479ffa1499fee8eb8/triton-3.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68", size = 197824404, upload-time = "2026-06-17T19:53:42.772Z" }, +] + +[[package]] +name = "ty" +version = "0.0.56" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/07/fb29aea5235b0aa8ecfc4d1cc6ddf9fba8b863d67d96c6d345694d644c43/ty-0.0.56.tar.gz", hash = "sha256:84d114dc3796361c0fc72945016eabd74d46b9ee64f198cb0e485719704681e5", size = 6050123, upload-time = "2026-07-01T16:44:56.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/48/bce79e7ca5c1cc529d3e0d37ddd1121aea4b68a4f749974ad1cc77161871/ty-0.0.56-py3-none-linux_armv6l.whl", hash = "sha256:186d4a53e15747c947e1ec3d7eec8e345d8e40a1ca10e634c585db52497e87dd", size = 11643066, upload-time = "2026-07-01T16:44:18.374Z" }, + { url = "https://files.pythonhosted.org/packages/80/d1/22555d8a1d719661f10050f3865d877bbf497da908961c75fe22217dd18a/ty-0.0.56-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aae1a980fd9535da0469b7ba2b2e1b54a907743a5e0f442dd57eee9f5bfd034c", size = 11407487, upload-time = "2026-07-01T16:44:20.956Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2d/b3b7a74ce8bc59ef48843ad80179bb0d9598bbd6cfc0d11d519bdf6b1352/ty-0.0.56-py3-none-macosx_11_0_arm64.whl", hash = "sha256:afd3058c0a6c5f241e814734f133008c93ee805f61c9cf4ce7412b8822b5d9ad", size = 10962270, upload-time = "2026-07-01T16:44:22.959Z" }, + { url = "https://files.pythonhosted.org/packages/64/ac/6c2fd7de0304a8a7218a756af74f7e62a5e8540fdb175e0a869e51042345/ty-0.0.56-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:058b52f7a823ac13aae3cae30809dd6b5145794b64d8478f9ef38c75d79b4483", size = 11471406, upload-time = "2026-07-01T16:44:25.327Z" }, + { url = "https://files.pythonhosted.org/packages/50/b6/11d861156861c03c7726b74558f9a0e0092661aff83a4fda1279df28c425/ty-0.0.56-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c66e00c1522add1f2bbdd2e45828c953b35c306b7bef03ec9169c75a63699a0", size = 11445612, upload-time = "2026-07-01T16:44:27.531Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ba/09df108582090f3c0770ec4bc8675affed60248f6793a78d909be16211d9/ty-0.0.56-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40903d71c669a30691b5a5d5728056c7877a1bd6be4f233a38883a8b28cf34d7", size = 12093889, upload-time = "2026-07-01T16:44:29.548Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/dbb4b4ccb69cd64c209ae55b1ab788ace8222c2bc1f6845be9e7cbedbf25/ty-0.0.56-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63fe3947fe0c46c69a7d950e6832ee70a9ec17321fefbff3d2e3c20baf9e5bd0", size = 12666337, upload-time = "2026-07-01T16:44:31.586Z" }, + { url = "https://files.pythonhosted.org/packages/86/e9/73f903fe4a3d9ea02f26f57c1eb07e3b1029ec92b0e8c2364718893440e3/ty-0.0.56-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71a0c1a72f9854532e710e119b6871ffe4542c8a65146f1f65dcd78fecd885b4", size = 12280247, upload-time = "2026-07-01T16:44:33.637Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/cebd222495832f1a00dcd321ba25f3cab804221a4991b992c2178bec68ee/ty-0.0.56-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70d1665596494e24d8ebd198438872b5a56ec3cae5f2bcf6c673be797acc4e3c", size = 11991107, upload-time = "2026-07-01T16:44:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/b7/07/8f7337a07250f42d975cdb6decf47fc5b421e6c7da5e3e7be1e85f63a7e5/ty-0.0.56-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:778f99e51558afc1dbbe48ee38ab6aae7b31390ed8c1a1ef1499b295e9f1e82f", size = 12298970, upload-time = "2026-07-01T16:44:38.243Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b9/a52cd59034a48f5f18c6b155cc2cc36861d874b6d0af204b12c898024c3d/ty-0.0.56-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:867bc5708e0066bb4ff6c7db524bd5deea2676c62bfe71d3303138b3be850af0", size = 11425683, upload-time = "2026-07-01T16:44:40.473Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2e/48e42d33357d52eefb695c0c3fcfc96879b73668a7447d1d1e0ad774fedc/ty-0.0.56-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6012f4189c928edb330a37deb9930f982380bd4aa7c4b8e0428eec9651c7551", size = 11469258, upload-time = "2026-07-01T16:44:42.513Z" }, + { url = "https://files.pythonhosted.org/packages/d5/01/ad1b4138be1e3fa97863af3925aa2134f17a593240c35dc38c3429fb5ad1/ty-0.0.56-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee83de1a7ff4cc32837ec06134ce391d441bc5b35ecd8d3cfe053f120f3e4c1", size = 11758736, upload-time = "2026-07-01T16:44:44.567Z" }, + { url = "https://files.pythonhosted.org/packages/09/34/9d81967ff240eaa57e9249728ef7b7790747cf6d3c9a98ec86b2cfdcc8ee/ty-0.0.56-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:62619b3b0e2c6248ef30d3f0e2f2217ae9893040585be07f32324242f197cd6f", size = 12100242, upload-time = "2026-07-01T16:44:46.584Z" }, + { url = "https://files.pythonhosted.org/packages/c3/36/f51d4666d2de6cf33c1f3a1fcc4bb6b70b197dd6ceaa491eef71d78fe8e8/ty-0.0.56-py3-none-win32.whl", hash = "sha256:b30687bb5cd9729d34c889a289edf32770388d9bb05243e534e723fb45e0381b", size = 11093759, upload-time = "2026-07-01T16:44:49.171Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b4/8fb5d4acfa4afb152245b20fa263069a7547bd1f8e4bfca4eda280c897d7/ty-0.0.56-py3-none-win_amd64.whl", hash = "sha256:ad4c8c47b6f4e3f9ed3fc0b1a5d650088d229e17dd8f63c1826d6bbe94cc3235", size = 12100327, upload-time = "2026-07-01T16:44:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fc/6a183e71edde90d0c35c2303f23f7a45b6891d1a2c45daf7b8f869831e19/ty-0.0.56-py3-none-win_arm64.whl", hash = "sha256:57538f273d444a5f1293fa7860e967178afe3917611fc5eff16b64e1204fe0d6", size = 11538780, upload-time = "2026-07-01T16:44:53.8Z" }, +] + +[[package]] +name = "typer" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/41/06cce5dbb9f77591512957710ac709e60b12e6216a2f2d0d607fd49706e8/uvicorn-0.50.0.tar.gz", hash = "sha256:0c92e1bc2259cb7faa4fcef774a5966588f2e88542744550b66799fba10b76f1", size = 93257, upload-time = "2026-07-04T05:03:26.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/3a/eb70620ca2bf8213603d5c731460687c49fee38b0072f0b4a637781f0a53/uvicorn-0.50.0-py3-none-any.whl", hash = "sha256:05f0eb19edf38208f79f43df8a63081b48df31b0cd1e5997be957a4dc97d1b19", size = 72716, upload-time = "2026-07-04T05:03:24.848Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +]