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,951 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import copy
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import html
|
|
8
|
+
import json
|
|
9
|
+
import secrets
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable, Mapping
|
|
13
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from ..branding import env_get
|
|
20
|
+
from ..chatgpt_codex_static_provider import resolve_chatgpt_codex_static_model
|
|
21
|
+
from ..host_browser import open_url
|
|
22
|
+
from .base import (
|
|
23
|
+
ProviderAccountStatus,
|
|
24
|
+
ProviderAuthError,
|
|
25
|
+
ProviderLoginRequiredError,
|
|
26
|
+
ProviderModel,
|
|
27
|
+
ProviderReasoningEffort,
|
|
28
|
+
)
|
|
29
|
+
from .store import (
|
|
30
|
+
ProviderTokenRecord,
|
|
31
|
+
delete_provider_token,
|
|
32
|
+
load_provider_token,
|
|
33
|
+
save_provider_token,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
SESSION_EXPIRED_DETAIL = "session expired"
|
|
37
|
+
"""Stable ``detail`` value meaning the stored session must be re-authorized."""
|
|
38
|
+
|
|
39
|
+
_ISSUER = "https://auth.openai.com"
|
|
40
|
+
_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
|
41
|
+
_RESPONSES_URL = f"{_CODEX_BASE_URL}/responses"
|
|
42
|
+
_MODELS_URL = f"{_CODEX_BASE_URL}/models"
|
|
43
|
+
_DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
44
|
+
_DEFAULT_CODEX_COMPAT_VERSION = "0.144.6"
|
|
45
|
+
_CALLBACK_PORTS = (1455, 1457)
|
|
46
|
+
_BROWSER_TIMEOUT_SECONDS = 300.0
|
|
47
|
+
_DEVICE_TIMEOUT_SECONDS = 15.0 * 60.0
|
|
48
|
+
_REFRESH_SKEW_SECONDS = 300.0
|
|
49
|
+
_DEFAULT_TOKEN_LIFETIME_SECONDS = 3600.0
|
|
50
|
+
_ALLOWED_REQUESTS = frozenset({_RESPONSES_URL, _MODELS_URL})
|
|
51
|
+
_REFRESH_LOCKS: dict[str, threading.Lock] = {}
|
|
52
|
+
_REFRESH_LOCKS_GUARD = threading.Lock()
|
|
53
|
+
_LOGIN_LOCK = threading.Lock()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _client_id() -> str:
|
|
57
|
+
return str(env_get("ALYSIS_OPENAI_OAUTH_CLIENT_ID") or "").strip() or _DEFAULT_CLIENT_ID
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _codex_compat_version() -> str:
|
|
61
|
+
"""Codex catalog compatibility version, intentionally not Alysis Code's version."""
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
str(env_get("ALYSIS_OPENAI_CODEX_COMPAT_VERSION") or "").strip()
|
|
65
|
+
or _DEFAULT_CODEX_COMPAT_VERSION
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OpenAICodexSubscriptionAuth:
|
|
70
|
+
"""ChatGPT-backed Codex Responses auth for Alysis Code's native agent loop.
|
|
71
|
+
|
|
72
|
+
The model transport intentionally lives behind this adapter because the
|
|
73
|
+
ChatGPT Codex endpoint is a compatibility surface, not the ordinary OpenAI
|
|
74
|
+
API. Credentials are attached only to two exact, HTTPS allowlisted URLs.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
provider_id = "openai-codex"
|
|
78
|
+
display_name = "ChatGPT Codex subscription"
|
|
79
|
+
description = "Use Codex models from ChatGPT while keeping Alysis Code's native agent."
|
|
80
|
+
profile_name = "chatgpt-codex"
|
|
81
|
+
auth_hint = "Browser or device-code sign-in; credentials use Alysis Code's encrypted vault."
|
|
82
|
+
base_url = _CODEX_BASE_URL
|
|
83
|
+
protocol = "openai_responses"
|
|
84
|
+
supports_previous_response_id = False
|
|
85
|
+
supports_temperature = False
|
|
86
|
+
requires_streaming = True
|
|
87
|
+
|
|
88
|
+
def __init__(self, *, transport: httpx.BaseTransport | None = None) -> None:
|
|
89
|
+
self._transport = transport
|
|
90
|
+
self._models_cache: tuple[ProviderModel, ...] | None = None
|
|
91
|
+
|
|
92
|
+
def account_status(self) -> ProviderAccountStatus:
|
|
93
|
+
"""Report account state without ever raising.
|
|
94
|
+
|
|
95
|
+
A supervising app polls this to decide what to show the user, so every
|
|
96
|
+
failure has to come back as data. Reading the credential store can fail
|
|
97
|
+
on its own — a spawned process may not reach the OS keyring holding the
|
|
98
|
+
store's master key — so the load is guarded too, not just the refresh.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
record = load_provider_token(self.provider_id)
|
|
103
|
+
except ProviderAuthError as exc:
|
|
104
|
+
return ProviderAccountStatus(
|
|
105
|
+
connected=False,
|
|
106
|
+
verified=False,
|
|
107
|
+
detail=str(exc),
|
|
108
|
+
)
|
|
109
|
+
except Exception as exc: # noqa: BLE001 - a status probe must not crash its caller
|
|
110
|
+
return ProviderAccountStatus(
|
|
111
|
+
connected=False,
|
|
112
|
+
verified=False,
|
|
113
|
+
detail=f"Could not read stored ChatGPT credentials ({exc.__class__.__name__}).",
|
|
114
|
+
)
|
|
115
|
+
if record is None:
|
|
116
|
+
return ProviderAccountStatus(
|
|
117
|
+
connected=False,
|
|
118
|
+
detail="No ChatGPT subscription account is connected.",
|
|
119
|
+
)
|
|
120
|
+
try:
|
|
121
|
+
record = self._valid_record(record)
|
|
122
|
+
except ProviderLoginRequiredError:
|
|
123
|
+
# A stable marker, not the thrown message: this is the one state a
|
|
124
|
+
# supervising app must be able to branch on to prompt a re-login.
|
|
125
|
+
return ProviderAccountStatus(
|
|
126
|
+
connected=False,
|
|
127
|
+
verified=True,
|
|
128
|
+
account_label=record.account_label,
|
|
129
|
+
detail=SESSION_EXPIRED_DETAIL,
|
|
130
|
+
)
|
|
131
|
+
except ProviderAuthError as exc:
|
|
132
|
+
return ProviderAccountStatus(
|
|
133
|
+
connected=False,
|
|
134
|
+
verified=False,
|
|
135
|
+
account_label=record.account_label,
|
|
136
|
+
detail=str(exc),
|
|
137
|
+
)
|
|
138
|
+
except Exception as exc: # noqa: BLE001 - a status probe must not crash its caller
|
|
139
|
+
return ProviderAccountStatus(
|
|
140
|
+
connected=False,
|
|
141
|
+
verified=False,
|
|
142
|
+
account_label=record.account_label,
|
|
143
|
+
detail=f"Could not verify ChatGPT credentials ({exc.__class__.__name__}).",
|
|
144
|
+
)
|
|
145
|
+
return ProviderAccountStatus(
|
|
146
|
+
connected=True,
|
|
147
|
+
account_label=record.account_label,
|
|
148
|
+
detail="Connected with ChatGPT.",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def route_credential_scope(self) -> str:
|
|
152
|
+
"""Return a non-secret stable account scope for continuation isolation."""
|
|
153
|
+
|
|
154
|
+
record = load_provider_token(self.provider_id)
|
|
155
|
+
if record is None:
|
|
156
|
+
return ""
|
|
157
|
+
material = record.account_id or record.account_label or record.access_token
|
|
158
|
+
if not material:
|
|
159
|
+
return ""
|
|
160
|
+
return hashlib.sha256(f"alysis-route-scope:v1:{material}".encode()).hexdigest()
|
|
161
|
+
|
|
162
|
+
def login(
|
|
163
|
+
self,
|
|
164
|
+
method: str = "browser",
|
|
165
|
+
*,
|
|
166
|
+
output_write: Callable[[str], None] | None = None,
|
|
167
|
+
) -> ProviderAccountStatus:
|
|
168
|
+
normalized = str(method or "browser").strip().lower()
|
|
169
|
+
write = output_write or print
|
|
170
|
+
if normalized not in {"browser", "device-code"}:
|
|
171
|
+
raise ProviderAuthError(f"Unsupported ChatGPT sign-in method: {method!r}.")
|
|
172
|
+
if not _LOGIN_LOCK.acquire(blocking=False):
|
|
173
|
+
raise ProviderAuthError("Another ChatGPT sign-in is already in progress.")
|
|
174
|
+
try:
|
|
175
|
+
record = (
|
|
176
|
+
self._browser_login(write) if normalized == "browser" else self._device_login(write)
|
|
177
|
+
)
|
|
178
|
+
save_provider_token(self.provider_id, record)
|
|
179
|
+
self._models_cache = None
|
|
180
|
+
finally:
|
|
181
|
+
_LOGIN_LOCK.release()
|
|
182
|
+
return ProviderAccountStatus(
|
|
183
|
+
connected=True,
|
|
184
|
+
account_label=record.account_label,
|
|
185
|
+
detail="Connected with ChatGPT.",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def logout(self) -> ProviderAccountStatus:
|
|
189
|
+
record = load_provider_token(self.provider_id)
|
|
190
|
+
revoke_error: str | None = None
|
|
191
|
+
if record is not None:
|
|
192
|
+
try:
|
|
193
|
+
with self._client(timeout=10.0) as client:
|
|
194
|
+
response = client.post(
|
|
195
|
+
f"{_ISSUER}/oauth/revoke",
|
|
196
|
+
json={
|
|
197
|
+
"token": record.refresh_token,
|
|
198
|
+
"token_type_hint": "refresh_token",
|
|
199
|
+
"client_id": _client_id(),
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
if response.status_code >= 400:
|
|
203
|
+
revoke_error = f"remote revocation returned status {response.status_code}"
|
|
204
|
+
except Exception as exc: # noqa: BLE001
|
|
205
|
+
# Local credential removal is still deterministic. Remote
|
|
206
|
+
# revocation is best effort because auth services can be offline.
|
|
207
|
+
revoke_error = f"remote revocation failed: {type(exc).__name__}"
|
|
208
|
+
delete_provider_token(self.provider_id)
|
|
209
|
+
self._models_cache = None
|
|
210
|
+
return ProviderAccountStatus(
|
|
211
|
+
connected=False,
|
|
212
|
+
verified=revoke_error is None,
|
|
213
|
+
detail=(
|
|
214
|
+
"Disconnected locally."
|
|
215
|
+
if revoke_error is None
|
|
216
|
+
else f"Disconnected locally; {revoke_error}."
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def authorization_headers(
|
|
221
|
+
self,
|
|
222
|
+
url: str,
|
|
223
|
+
*,
|
|
224
|
+
force_refresh: bool = False,
|
|
225
|
+
session_id: str | None = None,
|
|
226
|
+
) -> Mapping[str, str]:
|
|
227
|
+
normalized_url = _canonical_request_url(url)
|
|
228
|
+
if normalized_url not in _ALLOWED_REQUESTS:
|
|
229
|
+
raise ProviderAuthError(
|
|
230
|
+
"Refusing to attach ChatGPT credentials to a non-Codex destination."
|
|
231
|
+
)
|
|
232
|
+
record = load_provider_token(self.provider_id)
|
|
233
|
+
if record is None:
|
|
234
|
+
raise ProviderLoginRequiredError(
|
|
235
|
+
"ChatGPT is not connected. Run `alysis auth login openai-codex`."
|
|
236
|
+
)
|
|
237
|
+
record = self._valid_record(record, force_refresh=force_refresh)
|
|
238
|
+
headers: dict[str, str] = {
|
|
239
|
+
"Authorization": f"Bearer {record.access_token}",
|
|
240
|
+
"ChatGPT-Account-Id": str(record.account_id or ""),
|
|
241
|
+
# The ChatGPT Codex backend gates model availability on the native
|
|
242
|
+
# Codex client identity as well as the account entitlement. Keep
|
|
243
|
+
# these headers paired with the compatibility version used for model
|
|
244
|
+
# discovery; an Alysis Code-specific identity can make catalog-listed
|
|
245
|
+
# models fail at inference time with a misleading 404.
|
|
246
|
+
"originator": "codex_cli_rs",
|
|
247
|
+
"User-Agent": f"codex_cli_rs/{_codex_compat_version()}",
|
|
248
|
+
}
|
|
249
|
+
if not record.account_id:
|
|
250
|
+
headers.pop("ChatGPT-Account-Id", None)
|
|
251
|
+
normalized_session = str(session_id or "").strip()
|
|
252
|
+
if normalized_session:
|
|
253
|
+
headers.update(
|
|
254
|
+
{
|
|
255
|
+
"session-id": normalized_session,
|
|
256
|
+
"x-session-affinity": normalized_session,
|
|
257
|
+
"X-Session-Id": normalized_session,
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
return headers
|
|
261
|
+
|
|
262
|
+
def adapt_responses_payload(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
263
|
+
adapted = copy.deepcopy(dict(payload))
|
|
264
|
+
input_items = adapted.get("input")
|
|
265
|
+
if isinstance(input_items, list):
|
|
266
|
+
instructions: list[str] = []
|
|
267
|
+
retained: list[Any] = []
|
|
268
|
+
for item in input_items:
|
|
269
|
+
if isinstance(item, dict) and str(item.get("role") or "") in {
|
|
270
|
+
"system",
|
|
271
|
+
"developer",
|
|
272
|
+
}:
|
|
273
|
+
text = _message_text(item.get("content"))
|
|
274
|
+
if text:
|
|
275
|
+
instructions.append(text)
|
|
276
|
+
continue
|
|
277
|
+
if isinstance(item, dict):
|
|
278
|
+
item = copy.deepcopy(item)
|
|
279
|
+
item.pop("id", None)
|
|
280
|
+
item.pop("status", None)
|
|
281
|
+
retained.append(item)
|
|
282
|
+
adapted["input"] = retained
|
|
283
|
+
if instructions:
|
|
284
|
+
existing = str(adapted.get("instructions") or "").strip()
|
|
285
|
+
combined = "\n\n".join(instructions)
|
|
286
|
+
adapted["instructions"] = f"{existing}\n\n{combined}" if existing else combined
|
|
287
|
+
|
|
288
|
+
adapted["store"] = False
|
|
289
|
+
adapted.pop("temperature", None)
|
|
290
|
+
adapted.pop("max_output_tokens", None)
|
|
291
|
+
adapted.pop("prompt_cache_retention", None)
|
|
292
|
+
adapted.pop("previous_response_id", None)
|
|
293
|
+
|
|
294
|
+
include = adapted.get("include")
|
|
295
|
+
include_values = list(include) if isinstance(include, list) else []
|
|
296
|
+
if "reasoning.encrypted_content" not in include_values:
|
|
297
|
+
include_values.append("reasoning.encrypted_content")
|
|
298
|
+
adapted["include"] = include_values
|
|
299
|
+
|
|
300
|
+
text_config = adapted.get("text")
|
|
301
|
+
if isinstance(text_config, dict):
|
|
302
|
+
text_config.setdefault("verbosity", "low")
|
|
303
|
+
else:
|
|
304
|
+
adapted["text"] = {"verbosity": "low"}
|
|
305
|
+
|
|
306
|
+
tools = adapted.get("tools")
|
|
307
|
+
if isinstance(tools, list):
|
|
308
|
+
for tool in tools:
|
|
309
|
+
if isinstance(tool, dict) and str(tool.get("type") or "") == "function":
|
|
310
|
+
tool["strict"] = False
|
|
311
|
+
tool["parameters"] = _sanitize_openai_tool_schema(
|
|
312
|
+
tool.get("parameters", {"type": "object"})
|
|
313
|
+
)
|
|
314
|
+
return adapted
|
|
315
|
+
|
|
316
|
+
def list_models(self, *, refresh: bool = False) -> tuple[ProviderModel, ...]:
|
|
317
|
+
if self._models_cache is not None and not refresh:
|
|
318
|
+
return self._models_cache
|
|
319
|
+
url = f"{_MODELS_URL}?{urlencode({'client_version': _codex_compat_version()})}"
|
|
320
|
+
headers = dict(self.authorization_headers(_MODELS_URL))
|
|
321
|
+
try:
|
|
322
|
+
with self._client(timeout=20.0) as client:
|
|
323
|
+
response = client.get(url, headers=headers)
|
|
324
|
+
except httpx.HTTPError as exc:
|
|
325
|
+
raise ProviderAuthError("Could not load models for this ChatGPT account.") from exc
|
|
326
|
+
if response.status_code == 401:
|
|
327
|
+
headers = dict(self.authorization_headers(_MODELS_URL, force_refresh=True))
|
|
328
|
+
try:
|
|
329
|
+
with self._client(timeout=20.0) as client:
|
|
330
|
+
response = client.get(url, headers=headers)
|
|
331
|
+
except httpx.HTTPError as exc:
|
|
332
|
+
raise ProviderAuthError(
|
|
333
|
+
"Could not load models for this ChatGPT account after refreshing credentials."
|
|
334
|
+
) from exc
|
|
335
|
+
if response.status_code >= 400:
|
|
336
|
+
raise ProviderAuthError(
|
|
337
|
+
f"ChatGPT model discovery failed with status {response.status_code}."
|
|
338
|
+
)
|
|
339
|
+
try:
|
|
340
|
+
data = response.json()
|
|
341
|
+
except ValueError as exc:
|
|
342
|
+
raise ProviderAuthError("ChatGPT returned an invalid model catalog.") from exc
|
|
343
|
+
raw_models = data.get("models") if isinstance(data, dict) else None
|
|
344
|
+
if not isinstance(raw_models, list):
|
|
345
|
+
raise ProviderAuthError("ChatGPT returned an invalid model catalog.")
|
|
346
|
+
|
|
347
|
+
parsed: list[tuple[int, ProviderModel]] = []
|
|
348
|
+
for raw in raw_models:
|
|
349
|
+
if not isinstance(raw, dict):
|
|
350
|
+
continue
|
|
351
|
+
model_id = str(raw.get("slug") or raw.get("id") or "").strip()
|
|
352
|
+
visibility = str(raw.get("visibility") or "list").strip().lower()
|
|
353
|
+
if not model_id or visibility in {"hide", "hidden"}:
|
|
354
|
+
continue
|
|
355
|
+
efforts: list[ProviderReasoningEffort] = []
|
|
356
|
+
raw_efforts = raw.get("supported_reasoning_levels")
|
|
357
|
+
if isinstance(raw_efforts, list):
|
|
358
|
+
for entry in raw_efforts:
|
|
359
|
+
if not isinstance(entry, dict):
|
|
360
|
+
continue
|
|
361
|
+
effort_id = str(entry.get("effort") or entry.get("id") or "").strip()
|
|
362
|
+
if not effort_id:
|
|
363
|
+
continue
|
|
364
|
+
efforts.append(
|
|
365
|
+
ProviderReasoningEffort(
|
|
366
|
+
id=effort_id,
|
|
367
|
+
label=effort_id.replace("_", " ").title(),
|
|
368
|
+
description=str(entry.get("description") or "").strip(),
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
static_model = resolve_chatgpt_codex_static_model(model_id)
|
|
372
|
+
if not efforts and static_model is not None:
|
|
373
|
+
efforts = [
|
|
374
|
+
ProviderReasoningEffort(
|
|
375
|
+
id=effort_id,
|
|
376
|
+
label=effort_id.replace("_", " ").title(),
|
|
377
|
+
description=description,
|
|
378
|
+
)
|
|
379
|
+
for effort_id, description in static_model.reasoning_efforts
|
|
380
|
+
]
|
|
381
|
+
try:
|
|
382
|
+
priority = int(raw.get("priority") or 9999)
|
|
383
|
+
except (TypeError, ValueError):
|
|
384
|
+
priority = static_model.priority if static_model is not None else 9999
|
|
385
|
+
input_modalities = _input_modalities(raw)
|
|
386
|
+
if (
|
|
387
|
+
static_model is not None
|
|
388
|
+
and "input_modalities" not in raw
|
|
389
|
+
and "supports_image_input" not in raw
|
|
390
|
+
):
|
|
391
|
+
input_modalities = static_model.input_modalities
|
|
392
|
+
parsed.append(
|
|
393
|
+
(
|
|
394
|
+
priority,
|
|
395
|
+
ProviderModel(
|
|
396
|
+
id=model_id,
|
|
397
|
+
label=str(raw.get("display_name") or model_id).strip(),
|
|
398
|
+
description=str(raw.get("description") or "").strip(),
|
|
399
|
+
reasoning_efforts=tuple(efforts),
|
|
400
|
+
default_reasoning_effort=(
|
|
401
|
+
str(raw.get("default_reasoning_level") or "").strip()
|
|
402
|
+
or (
|
|
403
|
+
static_model.default_reasoning_effort
|
|
404
|
+
if static_model is not None
|
|
405
|
+
else None
|
|
406
|
+
)
|
|
407
|
+
),
|
|
408
|
+
input_modalities=input_modalities,
|
|
409
|
+
context_window_tokens=(
|
|
410
|
+
_positive_int(raw.get("context_window"))
|
|
411
|
+
or (
|
|
412
|
+
static_model.context_window_tokens
|
|
413
|
+
if static_model is not None
|
|
414
|
+
else None
|
|
415
|
+
)
|
|
416
|
+
),
|
|
417
|
+
max_output_tokens=(
|
|
418
|
+
_positive_int(raw.get("max_output_tokens"))
|
|
419
|
+
or (
|
|
420
|
+
static_model.max_output_tokens if static_model is not None else None
|
|
421
|
+
)
|
|
422
|
+
),
|
|
423
|
+
),
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
parsed.sort(key=lambda pair: (pair[0], pair[1].label.casefold(), pair[1].id))
|
|
427
|
+
models = [model for _priority, model in parsed]
|
|
428
|
+
if models:
|
|
429
|
+
first = models[0]
|
|
430
|
+
models[0] = ProviderModel(
|
|
431
|
+
id=first.id,
|
|
432
|
+
label=first.label,
|
|
433
|
+
description=first.description,
|
|
434
|
+
is_default=True,
|
|
435
|
+
reasoning_efforts=first.reasoning_efforts,
|
|
436
|
+
default_reasoning_effort=first.default_reasoning_effort,
|
|
437
|
+
input_modalities=first.input_modalities,
|
|
438
|
+
context_window_tokens=first.context_window_tokens,
|
|
439
|
+
max_output_tokens=first.max_output_tokens,
|
|
440
|
+
)
|
|
441
|
+
self._models_cache = tuple(models)
|
|
442
|
+
return self._models_cache
|
|
443
|
+
|
|
444
|
+
def _valid_record(
|
|
445
|
+
self,
|
|
446
|
+
record: ProviderTokenRecord,
|
|
447
|
+
*,
|
|
448
|
+
force_refresh: bool = False,
|
|
449
|
+
) -> ProviderTokenRecord:
|
|
450
|
+
if not force_refresh and record.expires_at > time.time() + _REFRESH_SKEW_SECONDS:
|
|
451
|
+
return record
|
|
452
|
+
lock = _refresh_lock(self.provider_id)
|
|
453
|
+
with lock:
|
|
454
|
+
current = load_provider_token(self.provider_id)
|
|
455
|
+
if current is None:
|
|
456
|
+
raise ProviderLoginRequiredError("ChatGPT credentials were removed; connect again.")
|
|
457
|
+
if force_refresh and current.access_token != record.access_token:
|
|
458
|
+
return current
|
|
459
|
+
if not force_refresh and current.expires_at > time.time() + _REFRESH_SKEW_SECONDS:
|
|
460
|
+
return current
|
|
461
|
+
return self._refresh(
|
|
462
|
+
current,
|
|
463
|
+
allow_unexpired_fallback=not force_refresh,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
def _refresh(
|
|
467
|
+
self,
|
|
468
|
+
record: ProviderTokenRecord,
|
|
469
|
+
*,
|
|
470
|
+
allow_unexpired_fallback: bool,
|
|
471
|
+
) -> ProviderTokenRecord:
|
|
472
|
+
try:
|
|
473
|
+
with self._client(timeout=30.0) as client:
|
|
474
|
+
response = client.post(
|
|
475
|
+
f"{_ISSUER}/oauth/token",
|
|
476
|
+
data={
|
|
477
|
+
"grant_type": "refresh_token",
|
|
478
|
+
"refresh_token": record.refresh_token,
|
|
479
|
+
"client_id": _client_id(),
|
|
480
|
+
},
|
|
481
|
+
)
|
|
482
|
+
except httpx.HTTPError as exc:
|
|
483
|
+
if allow_unexpired_fallback and record.expires_at > time.time():
|
|
484
|
+
return record
|
|
485
|
+
raise ProviderAuthError(
|
|
486
|
+
"ChatGPT credential refresh is temporarily unavailable; try again shortly."
|
|
487
|
+
) from exc
|
|
488
|
+
if response.status_code >= 400:
|
|
489
|
+
error_code = _oauth_error_code(response)
|
|
490
|
+
if response.status_code in {400, 401} or error_code in {
|
|
491
|
+
"invalid_grant",
|
|
492
|
+
"invalid_token",
|
|
493
|
+
}:
|
|
494
|
+
raise ProviderLoginRequiredError(
|
|
495
|
+
"ChatGPT session expired; connect your account again."
|
|
496
|
+
)
|
|
497
|
+
if allow_unexpired_fallback and record.expires_at > time.time():
|
|
498
|
+
return record
|
|
499
|
+
raise ProviderAuthError(
|
|
500
|
+
"ChatGPT credential refresh is temporarily unavailable; try again shortly."
|
|
501
|
+
)
|
|
502
|
+
data = _json_object(response, "ChatGPT returned an invalid refresh response.")
|
|
503
|
+
access_token = str(data.get("access_token") or "").strip()
|
|
504
|
+
refresh_token = str(data.get("refresh_token") or record.refresh_token).strip()
|
|
505
|
+
if not access_token or not refresh_token:
|
|
506
|
+
raise ProviderLoginRequiredError("ChatGPT session expired; connect your account again.")
|
|
507
|
+
id_token = str(data.get("id_token") or "").strip()
|
|
508
|
+
claims = _jwt_claims(id_token) if id_token else _jwt_claims(access_token)
|
|
509
|
+
refreshed = ProviderTokenRecord(
|
|
510
|
+
access_token=access_token,
|
|
511
|
+
refresh_token=refresh_token,
|
|
512
|
+
expires_at=_token_expiry(data, access_token),
|
|
513
|
+
account_id=_account_id(claims) or record.account_id,
|
|
514
|
+
account_label=_account_label(claims) or record.account_label,
|
|
515
|
+
)
|
|
516
|
+
save_provider_token(self.provider_id, refreshed)
|
|
517
|
+
return refreshed
|
|
518
|
+
|
|
519
|
+
def _browser_login(self, write: Callable[[str], None]) -> ProviderTokenRecord:
|
|
520
|
+
verifier = _pkce_verifier()
|
|
521
|
+
challenge = _pkce_challenge(verifier)
|
|
522
|
+
expected_state = secrets.token_urlsafe(32)
|
|
523
|
+
server = _bind_callback_server()
|
|
524
|
+
actual_port = int(server.server_address[1])
|
|
525
|
+
redirect_uri = f"http://localhost:{actual_port}/auth/callback"
|
|
526
|
+
params = {
|
|
527
|
+
"response_type": "code",
|
|
528
|
+
"client_id": _client_id(),
|
|
529
|
+
"redirect_uri": redirect_uri,
|
|
530
|
+
"scope": "openid profile email offline_access",
|
|
531
|
+
"code_challenge": challenge,
|
|
532
|
+
"code_challenge_method": "S256",
|
|
533
|
+
"id_token_add_organizations": "true",
|
|
534
|
+
"codex_cli_simplified_flow": "true",
|
|
535
|
+
"state": expected_state,
|
|
536
|
+
"originator": "alysis",
|
|
537
|
+
}
|
|
538
|
+
auth_url = f"{_ISSUER}/oauth/authorize?{urlencode(params)}"
|
|
539
|
+
callback: dict[str, str] = {}
|
|
540
|
+
server.RequestHandlerClass = _callback_handler(callback, expected_state)
|
|
541
|
+
server.timeout = 0.5
|
|
542
|
+
write("Opening ChatGPT sign-in in your browser…")
|
|
543
|
+
if not open_url(auth_url):
|
|
544
|
+
write(f"Open this URL to continue: {auth_url}")
|
|
545
|
+
deadline = time.monotonic() + _BROWSER_TIMEOUT_SECONDS
|
|
546
|
+
try:
|
|
547
|
+
while time.monotonic() < deadline and "done" not in callback:
|
|
548
|
+
server.handle_request()
|
|
549
|
+
finally:
|
|
550
|
+
server.server_close()
|
|
551
|
+
if "done" not in callback:
|
|
552
|
+
raise ProviderAuthError("ChatGPT browser sign-in timed out after five minutes.")
|
|
553
|
+
if callback.get("error"):
|
|
554
|
+
raise ProviderAuthError(f"ChatGPT sign-in failed: {callback['error']}")
|
|
555
|
+
code = callback.get("code") or ""
|
|
556
|
+
if not code:
|
|
557
|
+
raise ProviderAuthError("ChatGPT sign-in did not return an authorization code.")
|
|
558
|
+
return self._exchange_code(code=code, verifier=verifier, redirect_uri=redirect_uri)
|
|
559
|
+
|
|
560
|
+
def _device_login(self, write: Callable[[str], None]) -> ProviderTokenRecord:
|
|
561
|
+
try:
|
|
562
|
+
with self._client(timeout=30.0) as client:
|
|
563
|
+
response = client.post(
|
|
564
|
+
f"{_ISSUER}/api/accounts/deviceauth/usercode",
|
|
565
|
+
json={"client_id": _client_id()},
|
|
566
|
+
)
|
|
567
|
+
except httpx.HTTPError as exc:
|
|
568
|
+
raise ProviderAuthError("Could not start ChatGPT device-code sign-in.") from exc
|
|
569
|
+
if response.status_code >= 400:
|
|
570
|
+
raise ProviderAuthError(
|
|
571
|
+
f"ChatGPT device-code sign-in is unavailable (status {response.status_code})."
|
|
572
|
+
)
|
|
573
|
+
data = _json_object(response, "ChatGPT returned an invalid device-code response.")
|
|
574
|
+
device_auth_id = str(data.get("device_auth_id") or "").strip()
|
|
575
|
+
user_code = str(data.get("user_code") or data.get("usercode") or "").strip()
|
|
576
|
+
try:
|
|
577
|
+
interval = max(1.0, float(data.get("interval") or 5.0))
|
|
578
|
+
except (TypeError, ValueError):
|
|
579
|
+
interval = 5.0
|
|
580
|
+
if not device_auth_id or not user_code:
|
|
581
|
+
raise ProviderAuthError("ChatGPT returned an invalid device-code response.")
|
|
582
|
+
verification_url = f"{_ISSUER}/codex/device"
|
|
583
|
+
write(f"Open {verification_url}")
|
|
584
|
+
write(f"Enter this one-time code: {user_code}")
|
|
585
|
+
deadline = time.monotonic() + _DEVICE_TIMEOUT_SECONDS
|
|
586
|
+
code_data: dict[str, Any] | None = None
|
|
587
|
+
while time.monotonic() < deadline:
|
|
588
|
+
try:
|
|
589
|
+
with self._client(timeout=30.0) as client:
|
|
590
|
+
poll = client.post(
|
|
591
|
+
f"{_ISSUER}/api/accounts/deviceauth/token",
|
|
592
|
+
json={"device_auth_id": device_auth_id, "user_code": user_code},
|
|
593
|
+
)
|
|
594
|
+
except httpx.HTTPError as exc:
|
|
595
|
+
raise ProviderAuthError("ChatGPT device-code sign-in was interrupted.") from exc
|
|
596
|
+
if poll.status_code < 400:
|
|
597
|
+
code_data = _json_object(poll, "ChatGPT returned an invalid device token.")
|
|
598
|
+
break
|
|
599
|
+
if poll.status_code not in {403, 404}:
|
|
600
|
+
raise ProviderAuthError(
|
|
601
|
+
f"ChatGPT device-code sign-in failed with status {poll.status_code}."
|
|
602
|
+
)
|
|
603
|
+
time.sleep(min(interval, max(0.1, deadline - time.monotonic())))
|
|
604
|
+
if code_data is None:
|
|
605
|
+
raise ProviderAuthError("ChatGPT device-code sign-in timed out after 15 minutes.")
|
|
606
|
+
code = str(code_data.get("authorization_code") or "").strip()
|
|
607
|
+
verifier = str(code_data.get("code_verifier") or "").strip()
|
|
608
|
+
if not code or not verifier:
|
|
609
|
+
raise ProviderAuthError("ChatGPT returned an invalid device authorization.")
|
|
610
|
+
return self._exchange_code(
|
|
611
|
+
code=code,
|
|
612
|
+
verifier=verifier,
|
|
613
|
+
redirect_uri=f"{_ISSUER}/deviceauth/callback",
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
def _exchange_code(
|
|
617
|
+
self,
|
|
618
|
+
*,
|
|
619
|
+
code: str,
|
|
620
|
+
verifier: str,
|
|
621
|
+
redirect_uri: str,
|
|
622
|
+
) -> ProviderTokenRecord:
|
|
623
|
+
try:
|
|
624
|
+
with self._client(timeout=30.0) as client:
|
|
625
|
+
response = client.post(
|
|
626
|
+
f"{_ISSUER}/oauth/token",
|
|
627
|
+
data={
|
|
628
|
+
"grant_type": "authorization_code",
|
|
629
|
+
"code": code,
|
|
630
|
+
"redirect_uri": redirect_uri,
|
|
631
|
+
"client_id": _client_id(),
|
|
632
|
+
"code_verifier": verifier,
|
|
633
|
+
},
|
|
634
|
+
)
|
|
635
|
+
except httpx.HTTPError as exc:
|
|
636
|
+
raise ProviderAuthError("Could not complete ChatGPT token exchange.") from exc
|
|
637
|
+
if response.status_code >= 400:
|
|
638
|
+
raise ProviderAuthError(
|
|
639
|
+
f"ChatGPT token exchange failed with status {response.status_code}."
|
|
640
|
+
)
|
|
641
|
+
data = _json_object(response, "ChatGPT returned an invalid token response.")
|
|
642
|
+
access_token = str(data.get("access_token") or "").strip()
|
|
643
|
+
refresh_token = str(data.get("refresh_token") or "").strip()
|
|
644
|
+
id_token = str(data.get("id_token") or "").strip()
|
|
645
|
+
if not access_token or not refresh_token:
|
|
646
|
+
raise ProviderAuthError("ChatGPT token response was incomplete.")
|
|
647
|
+
claims = _jwt_claims(id_token) if id_token else _jwt_claims(access_token)
|
|
648
|
+
return ProviderTokenRecord(
|
|
649
|
+
access_token=access_token,
|
|
650
|
+
refresh_token=refresh_token,
|
|
651
|
+
expires_at=_token_expiry(data, access_token),
|
|
652
|
+
account_id=_account_id(claims),
|
|
653
|
+
account_label=_account_label(claims),
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
def _client(self, *, timeout: float) -> httpx.Client:
|
|
657
|
+
return httpx.Client(
|
|
658
|
+
timeout=timeout,
|
|
659
|
+
transport=self._transport,
|
|
660
|
+
follow_redirects=False,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _refresh_lock(provider_id: str) -> threading.Lock:
|
|
665
|
+
with _REFRESH_LOCKS_GUARD:
|
|
666
|
+
return _REFRESH_LOCKS.setdefault(provider_id, threading.Lock())
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _canonical_request_url(url: str) -> str:
|
|
670
|
+
split = urlsplit(str(url or ""))
|
|
671
|
+
if split.scheme != "https" or split.hostname != "chatgpt.com" or split.query or split.fragment:
|
|
672
|
+
return ""
|
|
673
|
+
port = split.port
|
|
674
|
+
if port not in {None, 443}:
|
|
675
|
+
return ""
|
|
676
|
+
return f"https://chatgpt.com{split.path.rstrip('/')}"
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _pkce_verifier() -> str:
|
|
680
|
+
return base64.urlsafe_b64encode(secrets.token_bytes(64)).decode("ascii").rstrip("=")
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _pkce_challenge(verifier: str) -> str:
|
|
684
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
685
|
+
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _bind_callback_server() -> HTTPServer:
|
|
689
|
+
last_error: OSError | None = None
|
|
690
|
+
for port in _CALLBACK_PORTS:
|
|
691
|
+
try:
|
|
692
|
+
return HTTPServer(("127.0.0.1", port), BaseHTTPRequestHandler)
|
|
693
|
+
except OSError as exc:
|
|
694
|
+
last_error = exc
|
|
695
|
+
raise ProviderAuthError("Could not open the local ChatGPT login callback port.") from last_error
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _callback_handler(
|
|
699
|
+
result: dict[str, str],
|
|
700
|
+
expected_state: str,
|
|
701
|
+
) -> type[BaseHTTPRequestHandler]:
|
|
702
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
703
|
+
def do_GET(self) -> None: # noqa: N802 - stdlib callback name
|
|
704
|
+
split = urlsplit(self.path)
|
|
705
|
+
if split.path != "/auth/callback":
|
|
706
|
+
self._respond(404, "Not found")
|
|
707
|
+
return
|
|
708
|
+
query = parse_qs(split.query, keep_blank_values=True)
|
|
709
|
+
state = str((query.get("state") or [""])[0])
|
|
710
|
+
if not hmac.compare_digest(state, expected_state):
|
|
711
|
+
self._respond(400, "State mismatch. Return to Alysis Code and try again.")
|
|
712
|
+
return
|
|
713
|
+
error = str((query.get("error_description") or query.get("error") or [""])[0])
|
|
714
|
+
if error:
|
|
715
|
+
result.update({"done": "1", "error": error})
|
|
716
|
+
self._respond(400, error)
|
|
717
|
+
return
|
|
718
|
+
code = str((query.get("code") or [""])[0])
|
|
719
|
+
if not code:
|
|
720
|
+
result.update({"done": "1", "error": "Missing authorization code."})
|
|
721
|
+
self._respond(400, "Missing authorization code.")
|
|
722
|
+
return
|
|
723
|
+
result.update({"done": "1", "code": code})
|
|
724
|
+
self._respond(200, "Connected. You can close this tab and return to Alysis Code.")
|
|
725
|
+
|
|
726
|
+
def log_message(self, _format: str, *_args: Any) -> None:
|
|
727
|
+
return
|
|
728
|
+
|
|
729
|
+
def _respond(self, status: int, message: str) -> None:
|
|
730
|
+
safe = html.escape(message, quote=True)
|
|
731
|
+
body = (
|
|
732
|
+
"<!doctype html><meta charset='utf-8'><title>Alysis Code sign-in</title>"
|
|
733
|
+
"<style>body{font-family:system-ui;max-width:42rem;margin:4rem auto;"
|
|
734
|
+
"padding:0 1.5rem;line-height:1.5}</style>"
|
|
735
|
+
f"<h1>Alysis Code</h1><p>{safe}</p>"
|
|
736
|
+
).encode()
|
|
737
|
+
self.send_response(status)
|
|
738
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
739
|
+
self.send_header("Content-Length", str(len(body)))
|
|
740
|
+
self.send_header("Cache-Control", "no-store")
|
|
741
|
+
self.end_headers()
|
|
742
|
+
self.wfile.write(body)
|
|
743
|
+
|
|
744
|
+
return CallbackHandler
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _json_object(response: httpx.Response, message: str) -> dict[str, Any]:
|
|
748
|
+
try:
|
|
749
|
+
data = response.json()
|
|
750
|
+
except ValueError as exc:
|
|
751
|
+
raise ProviderAuthError(message) from exc
|
|
752
|
+
if not isinstance(data, dict):
|
|
753
|
+
raise ProviderAuthError(message)
|
|
754
|
+
return data
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _oauth_error_code(response: httpx.Response) -> str:
|
|
758
|
+
try:
|
|
759
|
+
data = response.json()
|
|
760
|
+
except ValueError:
|
|
761
|
+
return ""
|
|
762
|
+
if not isinstance(data, dict):
|
|
763
|
+
return ""
|
|
764
|
+
error = data.get("error")
|
|
765
|
+
if isinstance(error, dict):
|
|
766
|
+
return str(error.get("code") or error.get("type") or "").strip().lower()
|
|
767
|
+
return str(error or "").strip().lower()
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _jwt_claims(token: str) -> dict[str, Any]:
|
|
771
|
+
parts = str(token or "").split(".")
|
|
772
|
+
if len(parts) < 2 or not parts[1]:
|
|
773
|
+
return {}
|
|
774
|
+
payload = parts[1]
|
|
775
|
+
payload += "=" * (-len(payload) % 4)
|
|
776
|
+
try:
|
|
777
|
+
decoded = base64.urlsafe_b64decode(payload.encode("ascii"))
|
|
778
|
+
data = json.loads(decoded.decode("utf-8"))
|
|
779
|
+
except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
|
|
780
|
+
return {}
|
|
781
|
+
return data if isinstance(data, dict) else {}
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _auth_claims(claims: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
785
|
+
nested = claims.get("https://api.openai.com/auth")
|
|
786
|
+
return nested if isinstance(nested, dict) else {}
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _account_id(claims: Mapping[str, Any]) -> str | None:
|
|
790
|
+
for value in (
|
|
791
|
+
claims.get("chatgpt_account_id"),
|
|
792
|
+
_auth_claims(claims).get("chatgpt_account_id"),
|
|
793
|
+
):
|
|
794
|
+
normalized = str(value or "").strip()
|
|
795
|
+
if normalized:
|
|
796
|
+
return normalized
|
|
797
|
+
organizations = claims.get("organizations")
|
|
798
|
+
if isinstance(organizations, list) and organizations:
|
|
799
|
+
first = organizations[0]
|
|
800
|
+
if isinstance(first, dict):
|
|
801
|
+
normalized = str(first.get("id") or "").strip()
|
|
802
|
+
if normalized:
|
|
803
|
+
return normalized
|
|
804
|
+
return None
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _account_label(claims: Mapping[str, Any]) -> str | None:
|
|
808
|
+
email = str(claims.get("email") or "").strip()
|
|
809
|
+
profile = claims.get("https://api.openai.com/profile")
|
|
810
|
+
if not email and isinstance(profile, dict):
|
|
811
|
+
email = str(profile.get("email") or "").strip()
|
|
812
|
+
plan = str(_auth_claims(claims).get("chatgpt_plan_type") or "").strip()
|
|
813
|
+
if email and plan:
|
|
814
|
+
return f"{email} ({plan})"
|
|
815
|
+
return email or (f"ChatGPT {plan}" if plan else None)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _token_expiry(data: Mapping[str, Any], access_token: str) -> float:
|
|
819
|
+
try:
|
|
820
|
+
expires_in = float(data.get("expires_in") or 0)
|
|
821
|
+
except (TypeError, ValueError):
|
|
822
|
+
expires_in = 0
|
|
823
|
+
if expires_in > 0:
|
|
824
|
+
return time.time() + expires_in
|
|
825
|
+
claims = _jwt_claims(access_token)
|
|
826
|
+
try:
|
|
827
|
+
expiration = float(claims.get("exp") or 0)
|
|
828
|
+
except (TypeError, ValueError):
|
|
829
|
+
expiration = 0
|
|
830
|
+
return expiration if expiration > time.time() else time.time() + _DEFAULT_TOKEN_LIFETIME_SECONDS
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _message_text(content: Any) -> str:
|
|
834
|
+
if isinstance(content, str):
|
|
835
|
+
return content.strip()
|
|
836
|
+
if not isinstance(content, list):
|
|
837
|
+
return str(content or "").strip()
|
|
838
|
+
parts: list[str] = []
|
|
839
|
+
for item in content:
|
|
840
|
+
if isinstance(item, str):
|
|
841
|
+
parts.append(item)
|
|
842
|
+
elif isinstance(item, dict):
|
|
843
|
+
value = item.get("text") or item.get("content")
|
|
844
|
+
if isinstance(value, str):
|
|
845
|
+
parts.append(value)
|
|
846
|
+
return "\n".join(part for part in parts if part).strip()
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _positive_int(value: Any) -> int | None:
|
|
850
|
+
try:
|
|
851
|
+
parsed = int(value)
|
|
852
|
+
except (TypeError, ValueError):
|
|
853
|
+
return None
|
|
854
|
+
return parsed if parsed > 0 else None
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _input_modalities(raw: Mapping[str, Any]) -> tuple[str, ...]:
|
|
858
|
+
advertised = raw.get("input_modalities")
|
|
859
|
+
if isinstance(advertised, list):
|
|
860
|
+
values = tuple(
|
|
861
|
+
dict.fromkeys(
|
|
862
|
+
str(item or "").strip().lower() for item in advertised if str(item or "").strip()
|
|
863
|
+
)
|
|
864
|
+
)
|
|
865
|
+
if values:
|
|
866
|
+
return values
|
|
867
|
+
if raw.get("supports_image_input") is True:
|
|
868
|
+
return ("text", "image")
|
|
869
|
+
return ("text",)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _sanitize_openai_tool_schema(value: Any) -> Any:
|
|
873
|
+
"""Lower broad JSON Schema/MCP shapes to Codex-compatible tool schemas."""
|
|
874
|
+
|
|
875
|
+
supported_types = {"string", "number", "boolean", "integer", "object", "array", "null"}
|
|
876
|
+
composition_keys = ("anyOf", "oneOf", "allOf")
|
|
877
|
+
if isinstance(value, bool):
|
|
878
|
+
return {"type": "string"}
|
|
879
|
+
if isinstance(value, list):
|
|
880
|
+
return [_sanitize_openai_tool_schema(item) for item in value]
|
|
881
|
+
if not isinstance(value, dict):
|
|
882
|
+
return value
|
|
883
|
+
|
|
884
|
+
result: dict[str, Any] = {}
|
|
885
|
+
if isinstance(value.get("$ref"), str):
|
|
886
|
+
result["$ref"] = value["$ref"]
|
|
887
|
+
if isinstance(value.get("description"), str):
|
|
888
|
+
result["description"] = value["description"]
|
|
889
|
+
if "const" in value:
|
|
890
|
+
result["enum"] = [value["const"]]
|
|
891
|
+
elif isinstance(value.get("enum"), list):
|
|
892
|
+
result["enum"] = copy.deepcopy(value["enum"])
|
|
893
|
+
properties = value.get("properties")
|
|
894
|
+
if isinstance(properties, dict):
|
|
895
|
+
result["properties"] = {
|
|
896
|
+
str(key): _sanitize_openai_tool_schema(item) for key, item in properties.items()
|
|
897
|
+
}
|
|
898
|
+
required = value.get("required")
|
|
899
|
+
if isinstance(required, list):
|
|
900
|
+
result["required"] = [item for item in required if isinstance(item, str)]
|
|
901
|
+
if "items" in value:
|
|
902
|
+
result["items"] = _sanitize_openai_tool_schema(value["items"])
|
|
903
|
+
if "additionalProperties" in value:
|
|
904
|
+
additional = value["additionalProperties"]
|
|
905
|
+
result["additionalProperties"] = (
|
|
906
|
+
additional if isinstance(additional, bool) else _sanitize_openai_tool_schema(additional)
|
|
907
|
+
)
|
|
908
|
+
for key in composition_keys:
|
|
909
|
+
if isinstance(value.get(key), list):
|
|
910
|
+
result[key] = [_sanitize_openai_tool_schema(item) for item in value[key]]
|
|
911
|
+
for key in ("$defs", "definitions"):
|
|
912
|
+
definitions = value.get(key)
|
|
913
|
+
if isinstance(definitions, dict):
|
|
914
|
+
result[key] = {
|
|
915
|
+
str(name): _sanitize_openai_tool_schema(item) for name, item in definitions.items()
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
raw_type = value.get("type")
|
|
919
|
+
if isinstance(raw_type, str):
|
|
920
|
+
schema_types = [raw_type] if raw_type in supported_types else []
|
|
921
|
+
elif isinstance(raw_type, list):
|
|
922
|
+
schema_types = [
|
|
923
|
+
item for item in raw_type if isinstance(item, str) and item in supported_types
|
|
924
|
+
]
|
|
925
|
+
else:
|
|
926
|
+
schema_types = []
|
|
927
|
+
if not schema_types and ("$ref" in result or any(key in result for key in composition_keys)):
|
|
928
|
+
return result
|
|
929
|
+
if not schema_types:
|
|
930
|
+
if any(key in value for key in ("properties", "required", "additionalProperties")):
|
|
931
|
+
schema_types = ["object"]
|
|
932
|
+
elif any(key in value for key in ("items", "prefixItems")):
|
|
933
|
+
schema_types = ["array"]
|
|
934
|
+
elif "enum" in result or "format" in value:
|
|
935
|
+
schema_types = ["string"]
|
|
936
|
+
elif any(
|
|
937
|
+
key in value
|
|
938
|
+
for key in ("minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf")
|
|
939
|
+
):
|
|
940
|
+
schema_types = ["number"]
|
|
941
|
+
if not schema_types:
|
|
942
|
+
return {}
|
|
943
|
+
result["type"] = schema_types[0] if len(schema_types) == 1 else schema_types
|
|
944
|
+
if "object" in schema_types and "properties" not in result:
|
|
945
|
+
result["properties"] = {}
|
|
946
|
+
if "array" in schema_types and "items" not in result:
|
|
947
|
+
result["items"] = {"type": "string"}
|
|
948
|
+
return result
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
__all__ = ["SESSION_EXPIRED_DETAIL", "OpenAICodexSubscriptionAuth"]
|