alysis-code 0.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
TimeoutProfile = Literal["fetch", "search"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class HttpTimeoutBudget:
|
|
13
|
+
overall_s: float
|
|
14
|
+
connect_s: float
|
|
15
|
+
read_s: float
|
|
16
|
+
write_s: float
|
|
17
|
+
pool_s: float
|
|
18
|
+
|
|
19
|
+
def as_httpx_timeout(self) -> httpx.Timeout:
|
|
20
|
+
return httpx.Timeout(
|
|
21
|
+
connect=self.connect_s,
|
|
22
|
+
read=self.read_s,
|
|
23
|
+
write=self.write_s,
|
|
24
|
+
pool=self.pool_s,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_http_timeout_budget(overall_s: float, *, profile: TimeoutProfile) -> HttpTimeoutBudget:
|
|
29
|
+
total = float(overall_s)
|
|
30
|
+
if total <= 0:
|
|
31
|
+
raise ValueError("overall timeout must be > 0")
|
|
32
|
+
|
|
33
|
+
if profile == "fetch":
|
|
34
|
+
connect_cap = 5.0
|
|
35
|
+
write_cap = 10.0
|
|
36
|
+
pool_cap = 5.0
|
|
37
|
+
else:
|
|
38
|
+
connect_cap = 10.0
|
|
39
|
+
write_cap = 15.0
|
|
40
|
+
pool_cap = 10.0
|
|
41
|
+
|
|
42
|
+
return HttpTimeoutBudget(
|
|
43
|
+
overall_s=total,
|
|
44
|
+
connect_s=min(total, connect_cap),
|
|
45
|
+
read_s=total,
|
|
46
|
+
write_s=min(total, write_cap),
|
|
47
|
+
pool_s=min(total, pool_cap),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def format_http_timeout_error(
|
|
52
|
+
*,
|
|
53
|
+
operation: str,
|
|
54
|
+
budget: HttpTimeoutBudget,
|
|
55
|
+
error: httpx.TimeoutException,
|
|
56
|
+
) -> str:
|
|
57
|
+
if isinstance(error, httpx.ConnectTimeout):
|
|
58
|
+
phase = "connection setup"
|
|
59
|
+
elif isinstance(error, httpx.ReadTimeout):
|
|
60
|
+
phase = "response read"
|
|
61
|
+
elif isinstance(error, httpx.WriteTimeout):
|
|
62
|
+
phase = "request write"
|
|
63
|
+
elif isinstance(error, httpx.PoolTimeout):
|
|
64
|
+
phase = "connection-pool wait"
|
|
65
|
+
else:
|
|
66
|
+
phase = "network I/O"
|
|
67
|
+
|
|
68
|
+
budget_text = (
|
|
69
|
+
f"connect={budget.connect_s:g}s read={budget.read_s:g}s "
|
|
70
|
+
f"write={budget.write_s:g}s pool={budget.pool_s:g}s"
|
|
71
|
+
)
|
|
72
|
+
detail = str(error).strip()
|
|
73
|
+
if detail:
|
|
74
|
+
return (
|
|
75
|
+
f"{operation} timed out during {phase} "
|
|
76
|
+
f"({budget_text}; overall={budget.overall_s:g}s): {detail}"
|
|
77
|
+
)
|
|
78
|
+
return f"{operation} timed out during {phase} ({budget_text}; overall={budget.overall_s:g}s)."
|
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
import ipaddress
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from PIL import Image, UnidentifiedImageError
|
|
19
|
+
|
|
20
|
+
from ..branding import env_get
|
|
21
|
+
from ..config import AppConfig
|
|
22
|
+
from ..error_text import sanitize_error_summary
|
|
23
|
+
from ..profiles import get_active_profile, resolve_effective_base_url
|
|
24
|
+
|
|
25
|
+
_OUTPUT_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".webp"})
|
|
26
|
+
_SIZES = frozenset({"auto", "1024x1024", "1536x1024", "1024x1536"})
|
|
27
|
+
_QUALITIES = frozenset({"auto", "low", "medium", "high"})
|
|
28
|
+
_BACKGROUNDS = frozenset({"auto", "opaque", "transparent"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ImageGenerationError(RuntimeError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def plan_image_output_paths(
|
|
36
|
+
*,
|
|
37
|
+
root: Path,
|
|
38
|
+
output_path: str,
|
|
39
|
+
count: int,
|
|
40
|
+
) -> tuple[tuple[Path, str], ...]:
|
|
41
|
+
root = root.resolve()
|
|
42
|
+
raw = str(output_path or "").strip()
|
|
43
|
+
if not raw:
|
|
44
|
+
raise ImageGenerationError("output_path is required")
|
|
45
|
+
requested = Path(raw)
|
|
46
|
+
if requested.is_absolute():
|
|
47
|
+
raise ImageGenerationError("output_path must be relative to the workspace root")
|
|
48
|
+
if requested.suffix.lower() not in _OUTPUT_SUFFIXES:
|
|
49
|
+
raise ImageGenerationError("output_path must end in .png, .jpg, .jpeg, or .webp")
|
|
50
|
+
if isinstance(count, bool) or not isinstance(count, int) or count < 1 or count > 4:
|
|
51
|
+
raise ImageGenerationError("count must be an integer from 1 to 4")
|
|
52
|
+
|
|
53
|
+
candidates: list[Path] = []
|
|
54
|
+
if count == 1:
|
|
55
|
+
candidates.append(requested)
|
|
56
|
+
else:
|
|
57
|
+
candidates.extend(
|
|
58
|
+
requested.with_name(f"{requested.stem}-{index}{requested.suffix}")
|
|
59
|
+
for index in range(1, count + 1)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
planned: list[tuple[Path, str]] = []
|
|
63
|
+
for candidate in candidates:
|
|
64
|
+
resolved = (root / candidate).resolve()
|
|
65
|
+
try:
|
|
66
|
+
relative = resolved.relative_to(root)
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
raise ImageGenerationError("output_path escapes the workspace root") from exc
|
|
69
|
+
planned.append((resolved, relative.as_posix()))
|
|
70
|
+
return tuple(planned)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def generate_images(
|
|
74
|
+
*,
|
|
75
|
+
root: Path,
|
|
76
|
+
cfg: AppConfig,
|
|
77
|
+
fallback_api_key: str | None,
|
|
78
|
+
prompt: str,
|
|
79
|
+
output_path: str,
|
|
80
|
+
count: int = 1,
|
|
81
|
+
size: str = "auto",
|
|
82
|
+
quality: str = "auto",
|
|
83
|
+
background: str = "auto",
|
|
84
|
+
timeout_s: float | None = None,
|
|
85
|
+
transport: httpx.BaseTransport | None = None,
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
settings = cfg.image_generation
|
|
88
|
+
if not settings.enabled:
|
|
89
|
+
raise ImageGenerationError(
|
|
90
|
+
"Image generation is disabled. Enable image_generation.enabled first."
|
|
91
|
+
)
|
|
92
|
+
clean_prompt = str(prompt or "").strip()
|
|
93
|
+
if not clean_prompt:
|
|
94
|
+
raise ImageGenerationError("prompt is required")
|
|
95
|
+
if len(clean_prompt) > 32_000:
|
|
96
|
+
raise ImageGenerationError("prompt exceeds the 32,000 character limit")
|
|
97
|
+
if count > settings.max_images_per_call:
|
|
98
|
+
raise ImageGenerationError(
|
|
99
|
+
f"count exceeds configured max_images_per_call={settings.max_images_per_call}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
normalized_size = _enum_value(size, allowed=_SIZES, field="size")
|
|
103
|
+
normalized_quality = _enum_value(quality, allowed=_QUALITIES, field="quality")
|
|
104
|
+
normalized_background = _enum_value(
|
|
105
|
+
background,
|
|
106
|
+
allowed=_BACKGROUNDS,
|
|
107
|
+
field="background",
|
|
108
|
+
)
|
|
109
|
+
planned = plan_image_output_paths(root=root, output_path=output_path, count=count)
|
|
110
|
+
if normalized_background == "transparent" and any(
|
|
111
|
+
target.suffix.lower() in {".jpg", ".jpeg"} for target, _ in planned
|
|
112
|
+
):
|
|
113
|
+
raise ImageGenerationError("transparent background requires PNG or WebP output")
|
|
114
|
+
existing = [relative for target, relative in planned if target.exists()]
|
|
115
|
+
if existing:
|
|
116
|
+
raise ImageGenerationError(
|
|
117
|
+
"Refusing to overwrite existing image file(s): " + ", ".join(existing)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
base_url, api_key, extra_headers = _resolve_runtime(
|
|
121
|
+
cfg=cfg,
|
|
122
|
+
fallback_api_key=fallback_api_key,
|
|
123
|
+
)
|
|
124
|
+
endpoint = _generation_endpoint(base_url)
|
|
125
|
+
payload: dict[str, Any] = {
|
|
126
|
+
"model": settings.model,
|
|
127
|
+
"prompt": clean_prompt,
|
|
128
|
+
"n": count,
|
|
129
|
+
}
|
|
130
|
+
if normalized_size != "auto":
|
|
131
|
+
payload["size"] = normalized_size
|
|
132
|
+
if normalized_quality != "auto":
|
|
133
|
+
payload["quality"] = normalized_quality
|
|
134
|
+
if normalized_background != "auto":
|
|
135
|
+
payload["background"] = normalized_background
|
|
136
|
+
if "gpt-image" in settings.model.casefold():
|
|
137
|
+
payload["output_format"] = _provider_output_format(planned[0][0].suffix)
|
|
138
|
+
|
|
139
|
+
safe_extra_headers = {
|
|
140
|
+
str(name): str(value)
|
|
141
|
+
for name, value in extra_headers.items()
|
|
142
|
+
if str(name).strip().casefold()
|
|
143
|
+
not in {"authorization", "content-length", "content-type", "host"}
|
|
144
|
+
}
|
|
145
|
+
headers = {
|
|
146
|
+
"Accept": "application/json",
|
|
147
|
+
"Authorization": f"Bearer {api_key}",
|
|
148
|
+
"Content-Type": "application/json",
|
|
149
|
+
"User-Agent": "alysis-code/image-generation",
|
|
150
|
+
**safe_extra_headers,
|
|
151
|
+
}
|
|
152
|
+
effective_timeout = float(timeout_s or settings.timeout_s)
|
|
153
|
+
max_response_bytes = min(
|
|
154
|
+
count * (((settings.max_image_bytes + 2) // 3) * 4) + 1_000_000,
|
|
155
|
+
160_000_000,
|
|
156
|
+
)
|
|
157
|
+
started = time.perf_counter()
|
|
158
|
+
try:
|
|
159
|
+
with httpx.Client(
|
|
160
|
+
timeout=httpx.Timeout(effective_timeout),
|
|
161
|
+
follow_redirects=False,
|
|
162
|
+
transport=transport,
|
|
163
|
+
) as client:
|
|
164
|
+
with client.stream("POST", endpoint, headers=headers, json=payload) as streamed:
|
|
165
|
+
response = _bounded_response(
|
|
166
|
+
streamed,
|
|
167
|
+
max_bytes=max_response_bytes,
|
|
168
|
+
label="Image provider response",
|
|
169
|
+
)
|
|
170
|
+
except (httpx.HTTPError, OSError) as exc:
|
|
171
|
+
raise ImageGenerationError(
|
|
172
|
+
"Image provider request failed: " + sanitize_error_summary(str(exc))
|
|
173
|
+
) from exc
|
|
174
|
+
response_payload = _response_json(response)
|
|
175
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
176
|
+
detail = _provider_error_detail(response_payload, response.text)
|
|
177
|
+
raise ImageGenerationError(f"Image provider returned HTTP {response.status_code}: {detail}")
|
|
178
|
+
|
|
179
|
+
data = response_payload.get("data")
|
|
180
|
+
if not isinstance(data, list) or len(data) < count:
|
|
181
|
+
raise ImageGenerationError(
|
|
182
|
+
f"Image provider returned {len(data) if isinstance(data, list) else 0} "
|
|
183
|
+
f"image(s); expected {count}"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
endpoint_host = str(urlsplit(endpoint).hostname or "").casefold()
|
|
187
|
+
prepared: list[tuple[Path, str, bytes, dict[str, Any]]] = []
|
|
188
|
+
revised_prompts: list[str] = []
|
|
189
|
+
for index, ((target, relative), item) in enumerate(zip(planned, data, strict=False), start=1):
|
|
190
|
+
if not isinstance(item, dict):
|
|
191
|
+
raise ImageGenerationError(f"Image provider result {index} is not an object")
|
|
192
|
+
raw_bytes = _image_bytes_from_result(
|
|
193
|
+
item=item,
|
|
194
|
+
max_bytes=settings.max_image_bytes,
|
|
195
|
+
endpoint_host=endpoint_host,
|
|
196
|
+
timeout_s=effective_timeout,
|
|
197
|
+
transport=transport,
|
|
198
|
+
)
|
|
199
|
+
normalized_bytes, metadata = _normalize_and_validate_image(
|
|
200
|
+
raw_bytes=raw_bytes,
|
|
201
|
+
output_suffix=target.suffix,
|
|
202
|
+
max_bytes=settings.max_image_bytes,
|
|
203
|
+
max_pixels=settings.max_pixels,
|
|
204
|
+
)
|
|
205
|
+
prepared.append((target, relative, normalized_bytes, metadata))
|
|
206
|
+
revised_prompt = str(item.get("revised_prompt") or "").strip()
|
|
207
|
+
if revised_prompt:
|
|
208
|
+
revised_prompts.append(revised_prompt)
|
|
209
|
+
|
|
210
|
+
committed: list[Path] = []
|
|
211
|
+
try:
|
|
212
|
+
for target, _relative, image_bytes, _metadata in prepared:
|
|
213
|
+
_atomic_create_bytes(target, image_bytes)
|
|
214
|
+
committed.append(target)
|
|
215
|
+
except Exception:
|
|
216
|
+
for target in committed:
|
|
217
|
+
with suppress(OSError):
|
|
218
|
+
target.unlink()
|
|
219
|
+
raise
|
|
220
|
+
|
|
221
|
+
images: list[dict[str, Any]] = []
|
|
222
|
+
for _target, relative, image_bytes, metadata in prepared:
|
|
223
|
+
images.append(
|
|
224
|
+
{
|
|
225
|
+
"path": relative,
|
|
226
|
+
"bytes": len(image_bytes),
|
|
227
|
+
"sha256": hashlib.sha256(image_bytes).hexdigest(),
|
|
228
|
+
**metadata,
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
raw_usage = response_payload.get("usage")
|
|
232
|
+
return {
|
|
233
|
+
"status": "succeeded",
|
|
234
|
+
"model": settings.model,
|
|
235
|
+
"count": len(images),
|
|
236
|
+
"images": images,
|
|
237
|
+
"output_paths": [item["path"] for item in images],
|
|
238
|
+
"size": normalized_size,
|
|
239
|
+
"quality": normalized_quality,
|
|
240
|
+
"background": normalized_background,
|
|
241
|
+
"prompt_sha256": hashlib.sha256(clean_prompt.encode("utf-8")).hexdigest(),
|
|
242
|
+
"revised_prompts": revised_prompts,
|
|
243
|
+
"provider_request_id": str(response.headers.get("x-request-id") or "") or None,
|
|
244
|
+
"provider_usage": raw_usage if isinstance(raw_usage, dict) else None,
|
|
245
|
+
"elapsed_ms": max(0, int((time.perf_counter() - started) * 1000)),
|
|
246
|
+
"technical_validation": "decoded, dimension-bounded, format-normalized, atomically written",
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _resolve_runtime(
|
|
251
|
+
*,
|
|
252
|
+
cfg: AppConfig,
|
|
253
|
+
fallback_api_key: str | None,
|
|
254
|
+
) -> tuple[str, str, dict[str, str]]:
|
|
255
|
+
settings = cfg.image_generation
|
|
256
|
+
explicit_base_url = str(settings.base_url or "").strip()
|
|
257
|
+
if explicit_base_url:
|
|
258
|
+
base_url = explicit_base_url
|
|
259
|
+
extra_headers: dict[str, str] = {}
|
|
260
|
+
else:
|
|
261
|
+
profile = get_active_profile(cfg)
|
|
262
|
+
base_url = resolve_effective_base_url(cfg=cfg, profile=profile)
|
|
263
|
+
extra_headers = dict(profile.extra_headers)
|
|
264
|
+
|
|
265
|
+
configured_env = str(settings.api_key_env or "").strip()
|
|
266
|
+
if configured_env:
|
|
267
|
+
api_key = str(env_get(configured_env) or "").strip()
|
|
268
|
+
if not api_key:
|
|
269
|
+
raise ImageGenerationError(
|
|
270
|
+
f"Image API key environment variable {configured_env!r} is not set"
|
|
271
|
+
)
|
|
272
|
+
else:
|
|
273
|
+
api_key = str(env_get("ALYSIS_IMAGE_API_KEY") or fallback_api_key or "").strip()
|
|
274
|
+
if not api_key:
|
|
275
|
+
raise ImageGenerationError(
|
|
276
|
+
"No image API key is available. Set ALYSIS_IMAGE_API_KEY or "
|
|
277
|
+
"image_generation.api_key_env."
|
|
278
|
+
)
|
|
279
|
+
return base_url, api_key, extra_headers
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _generation_endpoint(base_url: str) -> str:
|
|
283
|
+
clean = str(base_url or "").strip().rstrip("/")
|
|
284
|
+
parts = urlsplit(clean)
|
|
285
|
+
if parts.scheme not in {"http", "https"} or not parts.netloc:
|
|
286
|
+
raise ImageGenerationError("Image generation base URL must be HTTP(S)")
|
|
287
|
+
if parts.username or parts.password or parts.query or parts.fragment:
|
|
288
|
+
raise ImageGenerationError("Image generation base URL cannot contain credentials or query")
|
|
289
|
+
if parts.path.rstrip("/").endswith("/images/generations"):
|
|
290
|
+
return clean
|
|
291
|
+
return clean + "/images/generations"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _response_json(response: httpx.Response) -> dict[str, Any]:
|
|
295
|
+
try:
|
|
296
|
+
payload = response.json()
|
|
297
|
+
except ValueError as exc:
|
|
298
|
+
raise ImageGenerationError(
|
|
299
|
+
f"Image provider returned invalid JSON (HTTP {response.status_code})"
|
|
300
|
+
) from exc
|
|
301
|
+
if not isinstance(payload, dict):
|
|
302
|
+
raise ImageGenerationError("Image provider response must be a JSON object")
|
|
303
|
+
return payload
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _bounded_response(
|
|
307
|
+
response: httpx.Response,
|
|
308
|
+
*,
|
|
309
|
+
max_bytes: int,
|
|
310
|
+
label: str,
|
|
311
|
+
) -> httpx.Response:
|
|
312
|
+
content_length = str(response.headers.get("content-length") or "").strip()
|
|
313
|
+
if content_length:
|
|
314
|
+
try:
|
|
315
|
+
declared_length = int(content_length)
|
|
316
|
+
except ValueError:
|
|
317
|
+
declared_length = 0
|
|
318
|
+
if declared_length > max_bytes:
|
|
319
|
+
raise ImageGenerationError(f"{label} exceeds the configured byte limit")
|
|
320
|
+
body = bytearray()
|
|
321
|
+
for chunk in response.iter_bytes():
|
|
322
|
+
if len(body) + len(chunk) > max_bytes:
|
|
323
|
+
raise ImageGenerationError(f"{label} exceeds the configured byte limit")
|
|
324
|
+
body.extend(chunk)
|
|
325
|
+
return httpx.Response(
|
|
326
|
+
status_code=response.status_code,
|
|
327
|
+
headers=response.headers,
|
|
328
|
+
content=bytes(body),
|
|
329
|
+
request=response.request,
|
|
330
|
+
extensions=dict(response.extensions),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _provider_error_detail(payload: dict[str, Any], fallback: str) -> str:
|
|
335
|
+
error = payload.get("error")
|
|
336
|
+
if isinstance(error, dict):
|
|
337
|
+
candidate = error.get("message") or error.get("detail") or error.get("code")
|
|
338
|
+
else:
|
|
339
|
+
candidate = error
|
|
340
|
+
return sanitize_error_summary(str(candidate or fallback or "provider error"))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _image_bytes_from_result(
|
|
344
|
+
*,
|
|
345
|
+
item: dict[str, Any],
|
|
346
|
+
max_bytes: int,
|
|
347
|
+
endpoint_host: str,
|
|
348
|
+
timeout_s: float,
|
|
349
|
+
transport: httpx.BaseTransport | None,
|
|
350
|
+
) -> bytes:
|
|
351
|
+
encoded = item.get("b64_json")
|
|
352
|
+
if isinstance(encoded, str) and encoded.strip():
|
|
353
|
+
try:
|
|
354
|
+
payload = base64.b64decode(encoded, validate=True)
|
|
355
|
+
except (binascii.Error, ValueError) as exc:
|
|
356
|
+
raise ImageGenerationError("Image provider returned invalid base64 image data") from exc
|
|
357
|
+
if len(payload) > max_bytes:
|
|
358
|
+
raise ImageGenerationError("Generated image exceeds configured max_image_bytes")
|
|
359
|
+
return payload
|
|
360
|
+
|
|
361
|
+
url = str(item.get("url") or "").strip()
|
|
362
|
+
if not url:
|
|
363
|
+
raise ImageGenerationError("Image provider result has neither b64_json nor url")
|
|
364
|
+
return _download_image(
|
|
365
|
+
url=url,
|
|
366
|
+
endpoint_host=endpoint_host,
|
|
367
|
+
max_bytes=max_bytes,
|
|
368
|
+
timeout_s=timeout_s,
|
|
369
|
+
transport=transport,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _download_image(
|
|
374
|
+
*,
|
|
375
|
+
url: str,
|
|
376
|
+
endpoint_host: str,
|
|
377
|
+
max_bytes: int,
|
|
378
|
+
timeout_s: float,
|
|
379
|
+
transport: httpx.BaseTransport | None,
|
|
380
|
+
) -> bytes:
|
|
381
|
+
parts = urlsplit(url)
|
|
382
|
+
host = str(parts.hostname or "").casefold()
|
|
383
|
+
if parts.scheme != "https" or not host or parts.username or parts.password:
|
|
384
|
+
raise ImageGenerationError("Provider image URL must be credential-free HTTPS")
|
|
385
|
+
if host != endpoint_host and not _public_network_host(host):
|
|
386
|
+
raise ImageGenerationError("Provider image URL resolves to a non-public network address")
|
|
387
|
+
try:
|
|
388
|
+
with httpx.Client(
|
|
389
|
+
timeout=httpx.Timeout(timeout_s),
|
|
390
|
+
follow_redirects=False,
|
|
391
|
+
transport=transport,
|
|
392
|
+
) as client:
|
|
393
|
+
with client.stream("GET", url, headers={"Accept": "image/*"}) as response:
|
|
394
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
395
|
+
raise ImageGenerationError(
|
|
396
|
+
f"Provider image download returned HTTP {response.status_code}"
|
|
397
|
+
)
|
|
398
|
+
content_length = response.headers.get("content-length")
|
|
399
|
+
if content_length and int(content_length) > max_bytes:
|
|
400
|
+
raise ImageGenerationError(
|
|
401
|
+
"Generated image download exceeds configured max_image_bytes"
|
|
402
|
+
)
|
|
403
|
+
chunks: list[bytes] = []
|
|
404
|
+
total = 0
|
|
405
|
+
for chunk in response.iter_bytes():
|
|
406
|
+
total += len(chunk)
|
|
407
|
+
if total > max_bytes:
|
|
408
|
+
raise ImageGenerationError(
|
|
409
|
+
"Generated image download exceeds configured max_image_bytes"
|
|
410
|
+
)
|
|
411
|
+
chunks.append(chunk)
|
|
412
|
+
except ImageGenerationError:
|
|
413
|
+
raise
|
|
414
|
+
except (httpx.HTTPError, OSError, ValueError) as exc:
|
|
415
|
+
raise ImageGenerationError(
|
|
416
|
+
"Generated image download failed: " + sanitize_error_summary(str(exc))
|
|
417
|
+
) from exc
|
|
418
|
+
return b"".join(chunks)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _public_network_host(host: str) -> bool:
|
|
422
|
+
if host in {"localhost", "localhost.localdomain"} or host.endswith(".local"):
|
|
423
|
+
return False
|
|
424
|
+
try:
|
|
425
|
+
literal = ipaddress.ip_address(host)
|
|
426
|
+
except ValueError:
|
|
427
|
+
literal = None
|
|
428
|
+
if literal is not None:
|
|
429
|
+
return literal.is_global
|
|
430
|
+
try:
|
|
431
|
+
addresses = {
|
|
432
|
+
ipaddress.ip_address(sockaddr[0])
|
|
433
|
+
for _family, _type, _proto, _canonname, sockaddr in socket.getaddrinfo(
|
|
434
|
+
host,
|
|
435
|
+
443,
|
|
436
|
+
type=socket.SOCK_STREAM,
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
except (OSError, ValueError):
|
|
440
|
+
return False
|
|
441
|
+
return bool(addresses) and all(address.is_global for address in addresses)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _normalize_and_validate_image(
|
|
445
|
+
*,
|
|
446
|
+
raw_bytes: bytes,
|
|
447
|
+
output_suffix: str,
|
|
448
|
+
max_bytes: int,
|
|
449
|
+
max_pixels: int,
|
|
450
|
+
) -> tuple[bytes, dict[str, Any]]:
|
|
451
|
+
try:
|
|
452
|
+
with Image.open(io.BytesIO(raw_bytes)) as opened:
|
|
453
|
+
width, height = opened.size
|
|
454
|
+
if width < 1 or height < 1 or width * height > max_pixels:
|
|
455
|
+
raise ImageGenerationError(
|
|
456
|
+
f"Generated image dimensions {width}x{height} exceed configured max_pixels"
|
|
457
|
+
)
|
|
458
|
+
opened.load()
|
|
459
|
+
image = opened.copy()
|
|
460
|
+
except ImageGenerationError:
|
|
461
|
+
raise
|
|
462
|
+
except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as exc:
|
|
463
|
+
raise ImageGenerationError("Provider returned invalid or unsupported image bytes") from exc
|
|
464
|
+
|
|
465
|
+
desired_format = _pillow_output_format(output_suffix)
|
|
466
|
+
output = io.BytesIO()
|
|
467
|
+
if desired_format == "JPEG":
|
|
468
|
+
if image.mode in {"RGBA", "LA"} or (image.mode == "P" and "transparency" in image.info):
|
|
469
|
+
rgba = image.convert("RGBA")
|
|
470
|
+
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
|
|
471
|
+
background.alpha_composite(rgba)
|
|
472
|
+
image = background.convert("RGB")
|
|
473
|
+
elif image.mode != "RGB":
|
|
474
|
+
image = image.convert("RGB")
|
|
475
|
+
image.save(output, format="JPEG", quality=95, optimize=True)
|
|
476
|
+
elif desired_format == "WEBP":
|
|
477
|
+
image.save(output, format="WEBP", quality=95, method=6)
|
|
478
|
+
else:
|
|
479
|
+
image.save(output, format="PNG", optimize=True)
|
|
480
|
+
normalized = output.getvalue()
|
|
481
|
+
if len(normalized) > max_bytes:
|
|
482
|
+
raise ImageGenerationError("Normalized image exceeds configured max_image_bytes")
|
|
483
|
+
has_alpha = image.mode in {"RGBA", "LA"} or (image.mode == "P" and "transparency" in image.info)
|
|
484
|
+
return normalized, {
|
|
485
|
+
"width": width,
|
|
486
|
+
"height": height,
|
|
487
|
+
"format": desired_format.lower(),
|
|
488
|
+
"mode": image.mode,
|
|
489
|
+
"has_alpha": bool(has_alpha),
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _atomic_create_bytes(path: Path, payload: bytes) -> None:
|
|
494
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
495
|
+
fd, temp_name = tempfile.mkstemp(
|
|
496
|
+
dir=path.parent,
|
|
497
|
+
prefix=f".{path.name}.",
|
|
498
|
+
suffix=".tmp",
|
|
499
|
+
)
|
|
500
|
+
temp_path = Path(temp_name)
|
|
501
|
+
try:
|
|
502
|
+
with os.fdopen(fd, "wb") as handle:
|
|
503
|
+
handle.write(payload)
|
|
504
|
+
handle.flush()
|
|
505
|
+
os.fsync(handle.fileno())
|
|
506
|
+
try:
|
|
507
|
+
os.link(temp_path, path)
|
|
508
|
+
except FileExistsError as exc:
|
|
509
|
+
raise ImageGenerationError(
|
|
510
|
+
f"Refusing to overwrite existing image file: {path.name}"
|
|
511
|
+
) from exc
|
|
512
|
+
except OSError:
|
|
513
|
+
# Filesystems without hard-link support still get an exclusive,
|
|
514
|
+
# fully-written target. The fallback never overwrites an existing path.
|
|
515
|
+
try:
|
|
516
|
+
target_fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
|
|
517
|
+
except FileExistsError as exc:
|
|
518
|
+
raise ImageGenerationError(
|
|
519
|
+
f"Refusing to overwrite existing image file: {path.name}"
|
|
520
|
+
) from exc
|
|
521
|
+
try:
|
|
522
|
+
with os.fdopen(target_fd, "wb") as target:
|
|
523
|
+
target.write(payload)
|
|
524
|
+
target.flush()
|
|
525
|
+
os.fsync(target.fileno())
|
|
526
|
+
except Exception:
|
|
527
|
+
with suppress(OSError):
|
|
528
|
+
path.unlink()
|
|
529
|
+
raise
|
|
530
|
+
finally:
|
|
531
|
+
with suppress(OSError):
|
|
532
|
+
temp_path.unlink()
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _enum_value(value: str, *, allowed: frozenset[str], field: str) -> str:
|
|
536
|
+
normalized = str(value or "auto").strip().lower() or "auto"
|
|
537
|
+
if normalized not in allowed:
|
|
538
|
+
raise ImageGenerationError(f"{field} must be one of: {', '.join(sorted(allowed))}")
|
|
539
|
+
return normalized
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _provider_output_format(suffix: str) -> str:
|
|
543
|
+
return "jpeg" if suffix.lower() in {".jpg", ".jpeg"} else suffix.lower().lstrip(".")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _pillow_output_format(suffix: str) -> str:
|
|
547
|
+
return {
|
|
548
|
+
".jpg": "JPEG",
|
|
549
|
+
".jpeg": "JPEG",
|
|
550
|
+
".webp": "WEBP",
|
|
551
|
+
".png": "PNG",
|
|
552
|
+
}[suffix.lower()]
|