polyrob 0.5.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.
- agents/README.md +453 -0
- agents/__init__.py +164 -0
- agents/base_agent.py +743 -0
- agents/personality/character.py +119 -0
- agents/personality/character_manager.py +309 -0
- agents/personality/characters/rob.character.json +56 -0
- agents/personality/characters/trump.character.json +63 -0
- agents/personality/persona_render.py +78 -0
- agents/personality/persona_resolver.py +115 -0
- agents/prompt/__init__.py +58 -0
- agents/prompt/base_prompt.py +92 -0
- agents/prompt/system.py +417 -0
- agents/task/README_PATH_MANAGEMENT.md +141 -0
- agents/task/__init__.py +162 -0
- agents/task/agent/__init__.py +70 -0
- agents/task/agent/agent_state.py +408 -0
- agents/task/agent/async_delegation.py +274 -0
- agents/task/agent/autonomy_state.py +275 -0
- agents/task/agent/conversation.py +75 -0
- agents/task/agent/core/__init__.py +6 -0
- agents/task/agent/core/aux_metering.py +61 -0
- agents/task/agent/core/background_review.py +157 -0
- agents/task/agent/core/construction.py +1179 -0
- agents/task/agent/core/conversational_exit.py +49 -0
- agents/task/agent/core/correspondent_gate.py +227 -0
- agents/task/agent/core/curator.py +246 -0
- agents/task/agent/core/episodic_digest.py +103 -0
- agents/task/agent/core/error_recovery.py +480 -0
- agents/task/agent/core/history_io.py +257 -0
- agents/task/agent/core/llm_provisioning.py +287 -0
- agents/task/agent/core/llm_runner.py +547 -0
- agents/task/agent/core/logging_io.py +276 -0
- agents/task/agent/core/loop_detection.py +262 -0
- agents/task/agent/core/memory_prefetch.py +325 -0
- agents/task/agent/core/memory_writer.py +667 -0
- agents/task/agent/core/model_introspection.py +82 -0
- agents/task/agent/core/model_swap.py +177 -0
- agents/task/agent/core/next_action_internal.py +1331 -0
- agents/task/agent/core/output_validation.py +303 -0
- agents/task/agent/core/project_context.py +272 -0
- agents/task/agent/core/resources.py +93 -0
- agents/task/agent/core/result_processing.py +389 -0
- agents/task/agent/core/run_loop.py +661 -0
- agents/task/agent/core/safety_lifecycle.py +228 -0
- agents/task/agent/core/secret_guard.py +293 -0
- agents/task/agent/core/self_wake.py +280 -0
- agents/task/agent/core/session_metadata.py +104 -0
- agents/task/agent/core/step.py +781 -0
- agents/task/agent/core/step_execution.py +398 -0
- agents/task/agent/core/step_telemetry.py +222 -0
- agents/task/agent/core/test_aux_metering.py +50 -0
- agents/task/agent/core/test_billing_failover.py +68 -0
- agents/task/agent/core/test_fatal_error_classifier.py +38 -0
- agents/task/agent/core/test_judge_validation.py +275 -0
- agents/task/agent/core/test_model_swap.py +190 -0
- agents/task/agent/core/test_reflection_offload.py +54 -0
- agents/task/agent/core/turn_input.py +8 -0
- agents/task/agent/core/untrusted_wrap.py +103 -0
- agents/task/agent/core/user_ingress.py +228 -0
- agents/task/agent/hitl_manager.py +396 -0
- agents/task/agent/log_sanitize.py +36 -0
- agents/task/agent/message_manager/config.py +97 -0
- agents/task/agent/message_manager/service.py +758 -0
- agents/task/agent/message_manager/tests.py +305 -0
- agents/task/agent/message_manager/tool_call_builder.py +539 -0
- agents/task/agent/message_manager/tool_message_repair.py +364 -0
- agents/task/agent/message_manager/views.py +143 -0
- agents/task/agent/messages/__init__.py +8 -0
- agents/task/agent/messages/builders.py +309 -0
- agents/task/agent/messages/compactor.py +611 -0
- agents/task/agent/messages/context_references.py +271 -0
- agents/task/agent/messages/filters.py +333 -0
- agents/task/agent/messages/guidance.py +328 -0
- agents/task/agent/messages/persistence.py +448 -0
- agents/task/agent/messages/retrieval.py +506 -0
- agents/task/agent/messages/sqlite_persistence.py +85 -0
- agents/task/agent/messages/test_compaction_upgrade.py +349 -0
- agents/task/agent/messages/test_persistence_subdir_fix.py +28 -0
- agents/task/agent/messages/test_sqlite_persistence.py +54 -0
- agents/task/agent/messages/token_counter.py +532 -0
- agents/task/agent/orchestrator.py +776 -0
- agents/task/agent/profile_manager.py +105 -0
- agents/task/agent/profile_registry.py +173 -0
- agents/task/agent/prompts.py +1090 -0
- agents/task/agent/scenario_registry.py +226 -0
- agents/task/agent/service.py +509 -0
- agents/task/agent/session.py +984 -0
- agents/task/agent/skill_discovery.py +110 -0
- agents/task/agent/skill_frontmatter.py +94 -0
- agents/task/agent/skill_manager.py +1201 -0
- agents/task/agent/skill_store.py +627 -0
- agents/task/agent/skill_validation.py +56 -0
- agents/task/agent/skill_writer.py +492 -0
- agents/task/agent/sub_agent_manager.py +1137 -0
- agents/task/agent/test_session_disk_recovery.py +34 -0
- agents/task/agent/tests.py +513 -0
- agents/task/agent/tool_call_tracker.py +511 -0
- agents/task/agent/views.py +469 -0
- agents/task/config.py +432 -0
- agents/task/constants.py +1384 -0
- agents/task/flag_defaults.py +63 -0
- agents/task/goals/__init__.py +1 -0
- agents/task/goals/autonomy_marker.py +28 -0
- agents/task/goals/board.py +690 -0
- agents/task/goals/completion_judge.py +247 -0
- agents/task/goals/context.py +62 -0
- agents/task/goals/dispatcher.py +848 -0
- agents/task/goals/escalation.py +102 -0
- agents/task/goals/planner.py +114 -0
- agents/task/logging_config.py +155 -0
- agents/task/path.py +991 -0
- agents/task/robust_parse_config.py +400 -0
- agents/task/runtime/__init__.py +0 -0
- agents/task/runtime/run_as_session.py +130 -0
- agents/task/runtime_safety.py +308 -0
- agents/task/session/__init__.py +5 -0
- agents/task/session/browser_pool.py +130 -0
- agents/task/session/cleanup.py +612 -0
- agents/task/session/execution.py +397 -0
- agents/task/session/feed.py +200 -0
- agents/task/session/hitl_ingress.py +201 -0
- agents/task/session/hooks.py +83 -0
- agents/task/session/multi_agent.py +243 -0
- agents/task/session/workspace.py +91 -0
- agents/task/session_registry.py +91 -0
- agents/task/session_route.py +35 -0
- agents/task/sqlite_session_registry.py +261 -0
- agents/task/surface_config.py +185 -0
- agents/task/telemetry/__init__.py +58 -0
- agents/task/telemetry/event_log.py +180 -0
- agents/task/telemetry/formatters.py +642 -0
- agents/task/telemetry/manager.py +571 -0
- agents/task/telemetry/memory_events.py +66 -0
- agents/task/telemetry/self_events.py +53 -0
- agents/task/telemetry/sequence.py +160 -0
- agents/task/telemetry/service.py +1265 -0
- agents/task/telemetry/views.py +882 -0
- agents/task/templates.py +98 -0
- agents/task/test_aux_chain.py +151 -0
- agents/task/test_aux_router.py +41 -0
- agents/task/tests/test_parse_robustness.py +170 -0
- agents/task/tool_defaults.py +110 -0
- agents/task/utils.py +762 -0
- agents/task/utils_json.py +768 -0
- agents/task/utils_webview.py +86 -0
- agents/task/workspace_context.py +229 -0
- agents/task_agent_lite.py +2398 -0
- api/README.md +683 -0
- api/TASK_API_DOCS.md +151 -0
- api/__init__.py +0 -0
- api/a2a/__init__.py +37 -0
- api/a2a/agent_card.py +337 -0
- api/a2a/client.py +657 -0
- api/a2a/endpoints.py +375 -0
- api/a2a/models.py +268 -0
- api/a2a/streaming.py +419 -0
- api/a2a/task_handler.py +929 -0
- api/admin_endpoints.py +1584 -0
- api/app.py +923 -0
- api/auth_constants.py +28 -0
- api/auth_endpoints.py +402 -0
- api/auth_state.py +36 -0
- api/chat_via_task.py +42 -0
- api/conversation_manager.py +231 -0
- api/dependencies.py +186 -0
- api/eip8004_endpoints.py +452 -0
- api/hyperliquid_models.py +174 -0
- api/hyperliquid_routes.py +450 -0
- api/interfaces.py +207 -0
- api/jwt_middleware.py +83 -0
- api/kb/__init__.py +1 -0
- api/kb/endpoints.py +314 -0
- api/mcp_models.py +279 -0
- api/mcp_routes.py +438 -0
- api/middleware.py +526 -0
- api/models.py +176 -0
- api/openai_compat/__init__.py +0 -0
- api/openai_compat/model_map.py +25 -0
- api/openai_compat/models.py +59 -0
- api/openai_compat/router.py +140 -0
- api/payment_endpoints.py +444 -0
- api/payment_verification.py +138 -0
- api/polymarket_models.py +155 -0
- api/polymarket_routes.py +501 -0
- api/pricing_endpoints.py +89 -0
- api/session_routing.py +54 -0
- api/skill_endpoints.py +453 -0
- api/task_http_api.py +1856 -0
- api/webhooks.py +52 -0
- api/x402_endpoints.py +280 -0
- avatar/README.md +44 -0
- avatar/__init__.py +1 -0
- avatar/config/rob.json +7 -0
- avatar/mindprint.js +395 -0
- avatar/renders/rob.meta.json +20 -0
- avatar/renders/rob.png +0 -0
- avatar/studio.html +373 -0
- avatar/webview/avatar-live.js +44 -0
- cli/README.md +89 -0
- cli/__init__.py +0 -0
- cli/cli_surface.py +67 -0
- cli/commands/__init__.py +0 -0
- cli/commands/_bootstrap.py +49 -0
- cli/commands/_errors.py +53 -0
- cli/commands/chat.py +973 -0
- cli/commands/config.py +100 -0
- cli/commands/dashboard.py +103 -0
- cli/commands/doctor.py +222 -0
- cli/commands/email.py +174 -0
- cli/commands/gateway.py +346 -0
- cli/commands/goals.py +509 -0
- cli/commands/init.py +276 -0
- cli/commands/journey.py +30 -0
- cli/commands/kb.py +280 -0
- cli/commands/model.py +107 -0
- cli/commands/owner.py +431 -0
- cli/commands/pfp.py +232 -0
- cli/commands/run.py +414 -0
- cli/commands/serve.py +41 -0
- cli/commands/session.py +684 -0
- cli/commands/skill_install.py +786 -0
- cli/commands/skills.py +128 -0
- cli/commands/subagents.py +100 -0
- cli/commands/surface.py +81 -0
- cli/commands/telegram.py +185 -0
- cli/commands/todos.py +209 -0
- cli/commands/tools.py +102 -0
- cli/commands/update.py +316 -0
- cli/commands/whatsapp.py +175 -0
- cli/config_store.py +276 -0
- cli/gitignore.py +43 -0
- cli/inventory.py +46 -0
- cli/keys.py +101 -0
- cli/persona.py +17 -0
- cli/polyrob.py +137 -0
- cli/ui/__init__.py +64 -0
- cli/ui/activity.py +141 -0
- cli/ui/app.py +538 -0
- cli/ui/banner.py +233 -0
- cli/ui/blocks.py +534 -0
- cli/ui/bootstrap_notice.py +47 -0
- cli/ui/commands/__init__.py +41 -0
- cli/ui/commands/h_cron.py +64 -0
- cli/ui/commands/h_journey.py +164 -0
- cli/ui/commands/h_kb.py +121 -0
- cli/ui/commands/h_learn.py +100 -0
- cli/ui/commands/h_mcp.py +215 -0
- cli/ui/commands/h_self.py +113 -0
- cli/ui/commands/h_skills.py +314 -0
- cli/ui/commands/handlers.py +1672 -0
- cli/ui/commands/registry.py +426 -0
- cli/ui/dialog.py +425 -0
- cli/ui/event_registry.py +110 -0
- cli/ui/event_specs.py +45 -0
- cli/ui/events.py +433 -0
- cli/ui/identity.py +15 -0
- cli/ui/lifecycle.py +159 -0
- cli/ui/live_hooks.py +43 -0
- cli/ui/model_selector.py +532 -0
- cli/ui/persistent_loop.py +181 -0
- cli/ui/pick.py +113 -0
- cli/ui/plain_renderer.py +351 -0
- cli/ui/renderer.py +384 -0
- cli/ui/rich_renderer.py +533 -0
- cli/ui/secrets.py +115 -0
- cli/ui/state.py +376 -0
- cli/ui/statusbar.py +231 -0
- cli/ui/streaming.py +254 -0
- cli/ui/terminal_render.py +105 -0
- cli/ui/theme.py +132 -0
- cli/update/__init__.py +6 -0
- cli/update/context.py +121 -0
- cli/update/detect.py +176 -0
- cli/update/engine.py +95 -0
- cli/update/migrate_guarded.py +55 -0
- cli/update/process_guard.py +215 -0
- cli/update/runners.py +78 -0
- cli/update/snapshot.py +270 -0
- cli/update/versions.py +205 -0
- core/README.md +377 -0
- core/__init__.py +174 -0
- core/assets.py +87 -0
- core/async_bridge.py +76 -0
- core/autonomy_runtime.py +259 -0
- core/base_component.py +302 -0
- core/bootstrap.py +681 -0
- core/bot.py +310 -0
- core/config.py +833 -0
- core/constants.py +126 -0
- core/container.py +462 -0
- core/db_manifest.py +101 -0
- core/embedding.py +57 -0
- core/env.py +39 -0
- core/exceptions.py +287 -0
- core/flags.py +144 -0
- core/flags_catalog.py +345 -0
- core/home_migration.py +62 -0
- core/identity.py +118 -0
- core/initialization.py +1004 -0
- core/instance.py +537 -0
- core/interactive_gate.py +96 -0
- core/logging.py +436 -0
- core/owner_doc_writer.py +176 -0
- core/pairing.py +193 -0
- core/path_safety.py +20 -0
- core/paths.py +27 -0
- core/payment_config.py +21 -0
- core/permissions.py +322 -0
- core/runtime_config.py +96 -0
- core/runtime_paths.py +135 -0
- core/seams.py +35 -0
- core/secret_scan.py +25 -0
- core/secret_scrub.py +71 -0
- core/secrets.py +35 -0
- core/security_logging_filter.py +148 -0
- core/self_context_writer.py +333 -0
- core/self_evolution.py +294 -0
- core/session_context.py +47 -0
- core/sqlite_util.py +59 -0
- core/surfaces/__init__.py +16 -0
- core/surfaces/access.py +118 -0
- core/surfaces/binding.py +88 -0
- core/surfaces/bootstrap.py +78 -0
- core/surfaces/circuit.py +168 -0
- core/surfaces/continuity.py +77 -0
- core/surfaces/correspondents.py +228 -0
- core/surfaces/dispatcher.py +216 -0
- core/surfaces/envelopes.py +75 -0
- core/surfaces/gc.py +30 -0
- core/surfaces/idempotency.py +57 -0
- core/surfaces/inbound_webhook.py +124 -0
- core/surfaces/media.py +32 -0
- core/surfaces/message_router.py +91 -0
- core/surfaces/outbound_allowlist.py +80 -0
- core/surfaces/outbound_dispatcher.py +110 -0
- core/surfaces/outbound_mirror.py +37 -0
- core/surfaces/outbound_queue.py +107 -0
- core/surfaces/outbound_target.py +12 -0
- core/surfaces/owner_admin.py +29 -0
- core/surfaces/proactive.py +78 -0
- core/surfaces/progress.py +108 -0
- core/surfaces/rate_bucket.py +21 -0
- core/surfaces/registry.py +51 -0
- core/surfaces/rendering.py +53 -0
- core/surfaces/send_policy.py +14 -0
- core/surfaces/serialize.py +24 -0
- core/surfaces/session_chat_registry.py +112 -0
- core/surfaces/session_policy.py +60 -0
- core/surfaces/surface.py +288 -0
- core/surfaces/transcription.py +88 -0
- core/surfaces/voice_echo.py +26 -0
- core/surfaces/voice_guard.py +23 -0
- core/tickers.py +174 -0
- core/tool_catalog.py +192 -0
- core/version.py +75 -0
- core/wallet/__init__.py +5 -0
- core/wallet/agent_wallet.py +59 -0
- core/wallet/audit_sink.py +61 -0
- core/wallet/config.py +70 -0
- core/wallet/factory.py +87 -0
- core/wallet/policy.py +104 -0
- core/wallet/signer.py +51 -0
- cron/README.md +65 -0
- cron/__init__.py +0 -0
- cron/delivery.py +277 -0
- cron/digest.py +124 -0
- cron/jobs.py +207 -0
- cron/runner.py +297 -0
- cron/schedule.py +194 -0
- cron/scheduler.py +201 -0
- cron/service.py +57 -0
- cron/wake_gate.py +223 -0
- data/__init__.py +1 -0
- data/prompts/autov2_prompts.json +87 -0
- data/prompts/skills/browser-automation/SKILL.md +43 -0
- data/prompts/skills/coding-workflow/SKILL.md +36 -0
- data/prompts/skills/crypto-trading-safety/SKILL.md +53 -0
- data/prompts/skills/document-writing/SKILL.md +33 -0
- data/prompts/skills/email-comms/SKILL.md +33 -0
- data/prompts/skills/file-data-ops/SKILL.md +35 -0
- data/prompts/skills/hyperliquid-account-review/SKILL.md +48 -0
- data/prompts/skills/hyperliquid-market-data/SKILL.md +52 -0
- data/prompts/skills/hyperliquid-trading/SKILL.md +59 -0
- data/prompts/skills/lead-research/SKILL.md +86 -0
- data/prompts/skills/market-research-brief/SKILL.md +104 -0
- data/prompts/skills/person-analyzer/SKILL.md +124 -0
- data/prompts/skills/polymarket-market-research/SKILL.md +54 -0
- data/prompts/skills/polymarket-portfolio-review/SKILL.md +50 -0
- data/prompts/skills/polymarket-trading/SKILL.md +55 -0
- data/prompts/skills/presentation-creator/SKILL.md +36 -0
- data/prompts/skills/project-analyzer/SKILL.md +169 -0
- data/prompts/skills/rules.json +268 -0
- data/prompts/skills/secret-handling/SKILL.md +20 -0
- data/prompts/skills/skill-authoring/SKILL.md +31 -0
- data/prompts/skills/skill-security-review/SKILL.md +27 -0
- data/prompts/skills/social-discovery/SKILL.md +57 -0
- data/prompts/skills/task-planning/SKILL.md +33 -0
- data/prompts/skills/web-research/SKILL.md +39 -0
- data/prompts/skills/web-scraping/SKILL.md +73 -0
- data/prompts/skills/x-engagement/SKILL.md +69 -0
- data/prompts/system_prompts.json +4 -0
- data/subscription_channels.json +5 -0
- modules/README.md +503 -0
- modules/__init__.py +124 -0
- modules/auth/__init__.py +13 -0
- modules/auth/api_key_manager.py +157 -0
- modules/auth/identity_mapper.py +380 -0
- modules/auth/siwe_auth.py +273 -0
- modules/auth/tier_manager.py +173 -0
- modules/base_module.py +190 -0
- modules/credits/__init__.py +29 -0
- modules/credits/balance_manager.py +237 -0
- modules/credits/cost_utils.py +129 -0
- modules/credits/pricing.py +139 -0
- modules/credits/unified_ledger.py +148 -0
- modules/credits/usage_meter.py +310 -0
- modules/credits/usage_tracker.py +674 -0
- modules/database/__init__.py +33 -0
- modules/database/audit_log.py +322 -0
- modules/database/auth_tables.py +276 -0
- modules/database/connection.py +644 -0
- modules/database/connection_pool.py +305 -0
- modules/database/conversation_contexts.py +446 -0
- modules/database/database_manager.py +237 -0
- modules/database/hyperliquid.py +375 -0
- modules/database/polymarket.py +574 -0
- modules/database/user_mcp_servers.py +728 -0
- modules/database/user_profiles.py +562 -0
- modules/database/utils.py +107 -0
- modules/database/x402_tables.py +92 -0
- modules/eip8004/README.md +1069 -0
- modules/eip8004/__init__.py +65 -0
- modules/eip8004/contracts.py +498 -0
- modules/eip8004/models.py +271 -0
- modules/eip8004/registration.py +148 -0
- modules/eip8004/reputation.py +367 -0
- modules/eip8004/validation.py +277 -0
- modules/llm/__init__.py +97 -0
- modules/llm/adapters.py +963 -0
- modules/llm/anthropic_client.py +831 -0
- modules/llm/available_models.py +106 -0
- modules/llm/brain_scrubber.py +137 -0
- modules/llm/cache_hints.py +162 -0
- modules/llm/deepseek_client.py +562 -0
- modules/llm/gemini_client.py +1367 -0
- modules/llm/llm_client.py +1139 -0
- modules/llm/llm_client_registry.py +188 -0
- modules/llm/llm_factory.py +237 -0
- modules/llm/llm_manager.py +1036 -0
- modules/llm/messages.py +374 -0
- modules/llm/model_registry.py +1935 -0
- modules/llm/nvidia_client.py +125 -0
- modules/llm/openai_client.py +800 -0
- modules/llm/openrouter_client.py +969 -0
- modules/llm/profiles.py +178 -0
- modules/llm/test_anthropic_cache_capture.py +35 -0
- modules/llm/test_cached_pricing_derivation.py +24 -0
- modules/llm/think_scrubber.py +444 -0
- modules/llm/token_counter.py +268 -0
- modules/memory/__init__.py +35 -0
- modules/memory/backend_factory.py +83 -0
- modules/memory/cache_manager.py +186 -0
- modules/memory/episodic.py +108 -0
- modules/memory/local_vector_memory_provider.py +552 -0
- modules/memory/memory_manager.py +225 -0
- modules/memory/models.py +312 -0
- modules/memory/provider.py +210 -0
- modules/memory/registry.py +205 -0
- modules/memory/sqlite_memory_provider.py +767 -0
- modules/memory/task/__init__.py +52 -0
- modules/memory/task/compaction_manager.py +157 -0
- modules/memory/task/context_retriever.py +809 -0
- modules/memory/task/hierarchical_memory.py +1002 -0
- modules/memory/task/lexical_retriever.py +71 -0
- modules/memory/task/null_context_manager.py +213 -0
- modules/memory/task/phase_manager.py +625 -0
- modules/memory/task/reflection_service.py +157 -0
- modules/memory/task/semantic_retriever.py +218 -0
- modules/memory/task/task_context_manager.py +1165 -0
- modules/memory/task/test_forgetting_engages.py +24 -0
- modules/memory/task/test_reflection_llm.py +39 -0
- modules/memory/task/test_threat_scan.py +53 -0
- modules/memory/task/threat_scan.py +90 -0
- modules/memory/test_sqlite_memory_provider.py +106 -0
- modules/memory/user_profile_manager.py +527 -0
- modules/payments/__init__.py +11 -0
- modules/payments/deposit_monitor.py +478 -0
- modules/payments/price_oracle.py +70 -0
- modules/payments/treasury_sweeper.py +401 -0
- modules/payments/wallet_generator.py +145 -0
- modules/pfp/__init__.py +1 -0
- modules/pfp/config.py +92 -0
- modules/pfp/mesh.py +565 -0
- modules/pfp/push.py +69 -0
- modules/pfp/renderer.py +85 -0
- modules/pfp/store.py +82 -0
- modules/skills/__init__.py +1 -0
- modules/skills/skill_usage.py +248 -0
- modules/transcription/__init__.py +24 -0
- modules/transcription/base.py +20 -0
- modules/transcription/faster_whisper_transcriber.py +55 -0
- modules/x402/README.md +249 -0
- modules/x402/__init__.py +22 -0
- modules/x402/invoicing.py +416 -0
- modules/x402/middleware.py +364 -0
- modules/x402/settlement_watcher.py +145 -0
- modules/x402/x402_integration.py +344 -0
- polyrob-0.5.0.dist-info/METADATA +482 -0
- polyrob-0.5.0.dist-info/RECORD +745 -0
- polyrob-0.5.0.dist-info/WHEEL +5 -0
- polyrob-0.5.0.dist-info/entry_points.txt +2 -0
- polyrob-0.5.0.dist-info/licenses/LICENSE +21 -0
- polyrob-0.5.0.dist-info/top_level.txt +12 -0
- surfaces/README.md +70 -0
- surfaces/__init__.py +0 -0
- surfaces/email/__init__.py +0 -0
- surfaces/email/dedup.py +44 -0
- surfaces/email/harness.py +187 -0
- surfaces/email/inbound.py +159 -0
- surfaces/email/seed.py +60 -0
- surfaces/email/surface.py +105 -0
- surfaces/telegram/__init__.py +0 -0
- surfaces/telegram/dedup.py +83 -0
- surfaces/telegram/harness.py +886 -0
- surfaces/telegram/inbound.py +145 -0
- surfaces/telegram/interactive_tools.py +49 -0
- surfaces/telegram/markdown.py +347 -0
- surfaces/telegram/rate_limit.py +167 -0
- surfaces/telegram/surface.py +187 -0
- surfaces/telegram/voice.py +52 -0
- surfaces/whatsapp/__init__.py +0 -0
- surfaces/whatsapp/client.py +51 -0
- surfaces/whatsapp/harness.py +62 -0
- surfaces/whatsapp/inbound.py +132 -0
- surfaces/whatsapp/surface.py +64 -0
- surfaces/whatsapp/window.py +34 -0
- tools/README.md +578 -0
- tools/__init__.py +424 -0
- tools/alchemy/__init__.py +5 -0
- tools/alchemy/alchemy_tool.py +389 -0
- tools/anysite/__init__.py +17 -0
- tools/anysite/client.py +88 -0
- tools/anysite/tool.py +89 -0
- tools/base_tool.py +468 -0
- tools/browser/__init__.py +22 -0
- tools/browser/actions.py +118 -0
- tools/browser/browser.py +1457 -0
- tools/browser/browser_manager.py +690 -0
- tools/browser/context.py +1748 -0
- tools/browser/playwright_utils.py +137 -0
- tools/browser/views.py +56 -0
- tools/code_exec/SANDBOX_SECURITY.md +144 -0
- tools/code_exec/__init__.py +178 -0
- tools/code_exec/backend.py +73 -0
- tools/code_exec/backends/__init__.py +1 -0
- tools/code_exec/backends/docker.py +779 -0
- tools/code_exec/backends/local_subprocess.py +154 -0
- tools/code_exec/env_policy.py +45 -0
- tools/code_exec/result.py +42 -0
- tools/code_exec/sandbox_guard.py +59 -0
- tools/code_exec/tool.py +227 -0
- tools/coding/__init__.py +50 -0
- tools/coding/edit.py +107 -0
- tools/coding/search.py +113 -0
- tools/coding/tool.py +353 -0
- tools/collabland/__init__.py +5 -0
- tools/collabland/collabland_tool.py +471 -0
- tools/controller/__init__.py +0 -0
- tools/controller/_helpers.py +165 -0
- tools/controller/action_registration.py +1765 -0
- tools/controller/approval.py +244 -0
- tools/controller/approval_interactive.py +90 -0
- tools/controller/delegation.py +195 -0
- tools/controller/execution.py +797 -0
- tools/controller/execution_context.py +141 -0
- tools/controller/hooks.py +135 -0
- tools/controller/introspection.py +344 -0
- tools/controller/mcp_registrar.py +190 -0
- tools/controller/message_send.py +31 -0
- tools/controller/registry/__init__.py +4 -0
- tools/controller/registry/schema_generators.py +395 -0
- tools/controller/registry/schema_sanitizer.py +406 -0
- tools/controller/registry/service.py +1266 -0
- tools/controller/registry/views.py +151 -0
- tools/controller/service.py +409 -0
- tools/controller/tool_management.py +334 -0
- tools/controller/types.py +28 -0
- tools/controller/views.py +450 -0
- tools/cronjob_tools.py +166 -0
- tools/crypto_trade_gate.py +59 -0
- tools/descriptors.py +452 -0
- tools/dom/__init__.py +0 -0
- tools/dom/history_tree_processor/service.py +244 -0
- tools/dom/history_tree_processor/view.py +99 -0
- tools/dom/service.py +471 -0
- tools/dom/views.py +346 -0
- tools/email_tool.py +403 -0
- tools/exceptions.py +169 -0
- tools/filesystem.py +1038 -0
- tools/filesystem_docproc.py +331 -0
- tools/filesystem_pdf.py +755 -0
- tools/git/__init__.py +42 -0
- tools/git/tool.py +258 -0
- tools/github/__init__.py +33 -0
- tools/github/client.py +94 -0
- tools/github/tool.py +237 -0
- tools/goal_tools.py +309 -0
- tools/hyperliquid/__init__.py +56 -0
- tools/hyperliquid/models.py +233 -0
- tools/hyperliquid/service.py +1680 -0
- tools/knowledge_ingest.py +950 -0
- tools/mcp/README.md +535 -0
- tools/mcp/__init__.py +52 -0
- tools/mcp/catalog.py +115 -0
- tools/mcp/config.py +337 -0
- tools/mcp/mcp_tool.py +1415 -0
- tools/mcp/param_coercion.py +289 -0
- tools/mcp/protocol.py +1520 -0
- tools/mcp/rate_limit.py +50 -0
- tools/mcp/security.py +424 -0
- tools/mcp/self_install.py +111 -0
- tools/mcp/server_manager.py +829 -0
- tools/mcp/subscriptions.py +72 -0
- tools/mcp/user_mcp_service.py +899 -0
- tools/mcp/validation_tracker.py +89 -0
- tools/mcp/views.py +346 -0
- tools/oauth/__init__.py +17 -0
- tools/oauth/manager.py +75 -0
- tools/oauth/provider.py +69 -0
- tools/oauth/providers/__init__.py +1 -0
- tools/oauth/providers/generic_oauth2.py +96 -0
- tools/perplexity_tool.py +280 -0
- tools/polymarket/__init__.py +41 -0
- tools/polymarket/clob_adapter.py +76 -0
- tools/polymarket/models.py +182 -0
- tools/polymarket/service.py +2082 -0
- tools/self_env/__init__.py +61 -0
- tools/self_env/tool.py +274 -0
- tools/shell/__init__.py +80 -0
- tools/shell/backend_pool.py +118 -0
- tools/shell/discipline.py +62 -0
- tools/shell/executor.py +157 -0
- tools/shell/loopback_allow.py +87 -0
- tools/shell/process_registry.py +113 -0
- tools/shell/process_tool.py +131 -0
- tools/shell/state.py +129 -0
- tools/shell/tool.py +151 -0
- tools/task_tool.py +637 -0
- tools/twitter_tool.py +2086 -0
- tools/user_directory.py +179 -0
- tools/web_fetch/__init__.py +3 -0
- tools/web_fetch/fetcher.py +144 -0
- tools/web_fetch/render.py +44 -0
- tools/web_fetch/tool.py +60 -0
- tools/x402/__init__.py +60 -0
- tools/x402/client.py +47 -0
- tools/x402/invoice_tool.py +143 -0
- tools/x402/real_client.py +250 -0
- tools/x402/service.py +131 -0
- utils/README.md +498 -0
- utils/__init__.py +46 -0
- utils/auth_utils.py +114 -0
- utils/bounded_collections.py +162 -0
- utils/circuit_breaker.py +389 -0
- utils/gif_utils.py +435 -0
- utils/markdown_utils.py +291 -0
- utils/message_utils.py +147 -0
- utils/metrics.py +36 -0
- utils/path_validator.py +227 -0
- utils/rate_limit_manager.py +399 -0
- utils/result_size.py +296 -0
- utils/time_utils.py +206 -0
- utils/user_utils.py +188 -0
- webview/README.md +111 -0
- webview/RENAME_AND_ALIGN_HANDOFF.md +200 -0
- webview/__init__.py +8 -0
- webview/activity.py +636 -0
- webview/owner_auth.py +108 -0
- webview/pages.py +448 -0
- webview/repair_sessions.py +399 -0
- webview/server.py +4323 -0
- webview/server_launcher.py +112 -0
- webview/static/css/activity.css +89 -0
- webview/static/css/chat.css +1772 -0
- webview/static/css/components.css +1190 -0
- webview/static/css/config-panel.css +651 -0
- webview/static/css/pages/admin.css +766 -0
- webview/static/css/pages/profile.css +231 -0
- webview/static/css/pages/settings.css +297 -0
- webview/static/css/pages/signin.css +316 -0
- webview/static/css/style.css +4250 -0
- webview/static/css/variables.css +147 -0
- webview/static/css/workspace-fullwidth-fix.css +109 -0
- webview/static/img/favicon.ico +0 -0
- webview/static/js/activity.js +394 -0
- webview/static/js/admin/activity.js +186 -0
- webview/static/js/admin/dashboard.js +163 -0
- webview/static/js/admin/user_detail.js +400 -0
- webview/static/js/admin/users.js +174 -0
- webview/static/js/admin/utils.js +214 -0
- webview/static/js/chat.js +3178 -0
- webview/static/js/config-panel.js +522 -0
- webview/static/js/constants.js +20 -0
- webview/static/js/error-handler.js +226 -0
- webview/static/js/ethers.min.js +1 -0
- webview/static/js/event-filter.js +137 -0
- webview/static/js/event-store.js +351 -0
- webview/static/js/file-attachments.js +374 -0
- webview/static/js/file-loader.js +163 -0
- webview/static/js/index.js +103 -0
- webview/static/js/performance-utils.js +196 -0
- webview/static/js/profile.js +396 -0
- webview/static/js/screenshot.js +386 -0
- webview/static/js/session.js +1527 -0
- webview/static/js/settings.js +1288 -0
- webview/static/js/sidebar-data.js +766 -0
- webview/static/js/sidebar-toggle.js +296 -0
- webview/static/js/socket.io.min.js +7 -0
- webview/static/js/stats.js +355 -0
- webview/static/js/ui-utils.js +420 -0
- webview/static/js/workspace.js +966 -0
- webview/stats_service.py +613 -0
- webview/templates/__init__.py +1 -0
- webview/templates/__pycache__/__init__.cpython-311.pyc +0 -0
- webview/templates/activity.html +36 -0
- webview/templates/admin/activity.html +122 -0
- webview/templates/admin/dashboard.html +112 -0
- webview/templates/admin/user_detail.html +228 -0
- webview/templates/admin/users.html +105 -0
- webview/templates/autonomy.html +60 -0
- webview/templates/error.html +133 -0
- webview/templates/finance.html +90 -0
- webview/templates/identity.html +73 -0
- webview/templates/index.html +42 -0
- webview/templates/layout.html +125 -0
- webview/templates/memory.html +59 -0
- webview/templates/owner_login.html +15 -0
- webview/templates/profile.html +121 -0
- webview/templates/session.html +312 -0
- webview/templates/settings.html +534 -0
- webview/templates/sidebar.html +112 -0
- webview/templates/signin.html +213 -0
- webview/templates/status.html +14 -0
- webview/templates/system.html +45 -0
- webview/webgate.py +196 -0
|
@@ -0,0 +1,969 @@
|
|
|
1
|
+
"""OpenRouter LLM API Client.
|
|
2
|
+
|
|
3
|
+
OpenRouter provides unified access to multiple AI models through a single API.
|
|
4
|
+
API is OpenAI-compatible, supporting Grok, Kimi, Qwen, and many others.
|
|
5
|
+
|
|
6
|
+
Docs: https://openrouter.ai/docs/quickstart
|
|
7
|
+
|
|
8
|
+
Refactored Dec 2025: All model configuration comes from model_registry.
|
|
9
|
+
No deprecated fallback constants - registry is the single source of truth.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
import json
|
|
16
|
+
from typing import List, Dict, Any, Optional, Union, Tuple
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
from openai import AsyncOpenAI
|
|
19
|
+
|
|
20
|
+
from modules.llm.llm_client import LLMClient, translate_llm_error
|
|
21
|
+
from modules.llm.token_counter import count_messages_tokens
|
|
22
|
+
from modules.llm.model_registry import get_model_config, ModelProvider
|
|
23
|
+
from core.exceptions import LLMError, ServiceError, LLMRateLimitError, LLMAuthenticationError
|
|
24
|
+
from core.config import BotConfig
|
|
25
|
+
|
|
26
|
+
# Import default model from registry (mirrors anthropic_client.py's pattern; no
|
|
27
|
+
# circular import — llm_client_registry only imports openrouter_client lazily,
|
|
28
|
+
# inside function bodies, never at module level).
|
|
29
|
+
from modules.llm.llm_client_registry import get_default_model
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# Kimi-K2 emits tool calls with special delimiter tokens. When a serving layer
|
|
34
|
+
# (notably NVIDIA NIM) doesn't parse them, they leak into ``message.content`` as
|
|
35
|
+
# raw text with no structured ``tool_calls`` — costing the agent the actions and
|
|
36
|
+
# dumping the tokens to output. Recover them client-side.
|
|
37
|
+
# <|tool_call_begin|> functions.{name}:{idx} <|tool_call_argument_begin|> {json} <|tool_call_end|>
|
|
38
|
+
#
|
|
39
|
+
# We locate each call's *header* (name + idx + the argument-begin marker) with a
|
|
40
|
+
# narrow regex, then decode the JSON args with ``json.JSONDecoder().raw_decode``
|
|
41
|
+
# from the first ``{`` so a complete JSON object is consumed regardless of any
|
|
42
|
+
# trailing tokens. The old ``(?P<args>\{.*?\})`` group truncated when a string
|
|
43
|
+
# value literally contained ``} <|tool_call_end|>`` (Bug 3).
|
|
44
|
+
_KIMI_TOOL_CALL_HEADER_RE = re.compile(
|
|
45
|
+
r"<\|tool_call_begin\|>\s*functions\.(?P<name>[\w.\-]+):(?P<idx>\d+)\s*"
|
|
46
|
+
r"<\|tool_call_argument_begin\|>\s*",
|
|
47
|
+
re.DOTALL,
|
|
48
|
+
)
|
|
49
|
+
#: Any Kimi tool-call control token — marks where the parseable content ends.
|
|
50
|
+
_KIMI_TOKEN_MARKER = "<|tool_call"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _new_recovered_call_id(tag: str, i: int) -> str:
|
|
54
|
+
"""Mint a GLOBALLY unique id for a recovered tool call (P0-4).
|
|
55
|
+
|
|
56
|
+
Recovered ids used to be deterministic per-response (``call_{i}_{idx}`` /
|
|
57
|
+
``call_txt_{i}``), so two recovery turns in one session produced DUPLICATE
|
|
58
|
+
ids across history. Downstream, ``tool_message_repair.repair_tool_message_pairs``
|
|
59
|
+
keys its ``tool_msg_map`` by id over the WHOLE history (last write wins) and
|
|
60
|
+
``del``s the id on first use — so step 1's AIMessage got paired with step 2's
|
|
61
|
+
tool result and step 2 got a fabricated "[ERROR: No response recorded...]"
|
|
62
|
+
placeholder. Textual leaks fire on ~20-25% of Kimi/NIM turns, making the
|
|
63
|
+
collision routine.
|
|
64
|
+
|
|
65
|
+
Mirrors the ``tool_call_builder.normalize_tool_call`` reference pattern
|
|
66
|
+
(uuid4 for missing ids), kept short/provider-safe: ``call_{tag}_{hex8}_{i}``.
|
|
67
|
+
The trailing enumeration index ``i`` keeps ids distinct within one response
|
|
68
|
+
even in the astronomically-unlikely event of a hex collision, and preserves
|
|
69
|
+
call ordering for debuggability.
|
|
70
|
+
"""
|
|
71
|
+
return f"call_{tag}_{uuid4().hex[:8]}_{i}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_kimi_tool_calls(content: str) -> List[Dict[str, Any]]:
|
|
75
|
+
"""Extract OpenAI-shape tool calls from Kimi's leaked delimiter tokens.
|
|
76
|
+
|
|
77
|
+
Returns ``[]`` when *content* carries no (valid) Kimi tool-call tokens, so it
|
|
78
|
+
is a safe no-op for every other model/provider. Args that don't parse as JSON
|
|
79
|
+
are skipped (with a distinct WARN) rather than guessed.
|
|
80
|
+
|
|
81
|
+
Recovered ids are **globally unique** via ``_new_recovered_call_id`` (uuid4
|
|
82
|
+
suffix). This subsumes the earlier per-response uniqueness fix (Bug 1: Kimi's
|
|
83
|
+
``idx`` restarts at 0 per function name, so two parallel calls could collide
|
|
84
|
+
within one response) AND the cross-response collision (P0-4: deterministic
|
|
85
|
+
ids repeated across recovery turns, corrupting ``tool_message_repair``'s
|
|
86
|
+
id-keyed history map). The enumeration index ``i`` is retained for ordering
|
|
87
|
+
and intra-response distinctness.
|
|
88
|
+
"""
|
|
89
|
+
text = content or ""
|
|
90
|
+
out: List[Dict[str, Any]] = []
|
|
91
|
+
decoder = json.JSONDecoder()
|
|
92
|
+
for i, m in enumerate(_KIMI_TOOL_CALL_HEADER_RE.finditer(text)):
|
|
93
|
+
name = m.group("name")
|
|
94
|
+
idx = m.group("idx")
|
|
95
|
+
# Args start at the first '{' after the argument-begin marker. raw_decode
|
|
96
|
+
# consumes exactly one complete JSON object and ignores trailing tokens,
|
|
97
|
+
# so an embedded ``} <|tool_call_end|>`` inside a string value no longer
|
|
98
|
+
# truncates the capture.
|
|
99
|
+
brace = text.find("{", m.end())
|
|
100
|
+
if brace == -1:
|
|
101
|
+
logger.warning(
|
|
102
|
+
f"event=kimi_toolcall_parse_error model=? name={name} idx={idx} "
|
|
103
|
+
f"reason=no_opening_brace (Kimi tool-call header without JSON args — dropped)"
|
|
104
|
+
)
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
obj, end = decoder.raw_decode(text, brace)
|
|
108
|
+
except (ValueError, TypeError) as e:
|
|
109
|
+
logger.warning(
|
|
110
|
+
f"event=kimi_toolcall_parse_error name={name} idx={idx} "
|
|
111
|
+
f"reason=json_decode_failed err={e} (Kimi tool-call args did not parse — dropped)"
|
|
112
|
+
)
|
|
113
|
+
continue
|
|
114
|
+
out.append({
|
|
115
|
+
"id": _new_recovered_call_id("kimi", i),
|
|
116
|
+
"type": "function",
|
|
117
|
+
"function": {"name": name, "arguments": json.dumps(obj)},
|
|
118
|
+
})
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def strip_kimi_tokens(content: str) -> str:
|
|
123
|
+
"""Drop everything from the first Kimi control token onward.
|
|
124
|
+
|
|
125
|
+
The brain-state text precedes the token soup, so this keeps it for brain
|
|
126
|
+
extraction while removing the unparsed tool-call tokens from what streams.
|
|
127
|
+
"""
|
|
128
|
+
if not content:
|
|
129
|
+
return content
|
|
130
|
+
cut = content.find(_KIMI_TOKEN_MARKER)
|
|
131
|
+
return content[:cut].rstrip() if cut != -1 else content
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def recover_kimi_content(content: str) -> Tuple[str, List[Dict[str, Any]]]:
|
|
135
|
+
"""Reconcile leaked Kimi control tokens in *content* into clean output.
|
|
136
|
+
|
|
137
|
+
Returns ``(cleaned_content, recovered_tool_calls)``. When no Kimi control
|
|
138
|
+
token is present this is a no-op: ``(content, [])``.
|
|
139
|
+
|
|
140
|
+
The key invariant (WS-2.1): **stripping is unconditional once any token
|
|
141
|
+
leaks**, independent of whether structured calls were recovered. Three leak
|
|
142
|
+
shapes were seen from kimi-k2.6 on NVIDIA NIM:
|
|
143
|
+
1. pure brain JSON (no tokens) → no marker, untouched
|
|
144
|
+
2. brain JSON + full ``<|tool_call_begin|>`` blocks → calls recovered + stripped
|
|
145
|
+
3. brain JSON + stray ``<|tool_call_end|>`` only → 0 calls but STILL stripped
|
|
146
|
+
The old code only stripped in case (2), so case (3) leaked raw tokens to the
|
|
147
|
+
user. This always strips, so the tokens become structurally impossible to leak.
|
|
148
|
+
"""
|
|
149
|
+
if not content or _KIMI_TOKEN_MARKER not in content:
|
|
150
|
+
return (content, [])
|
|
151
|
+
return (strip_kimi_tokens(content), parse_kimi_tool_calls(content))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Textual tool-call recovery (B4, 2026-06-16). kimi-k2.6 on NVIDIA NIM ALSO
|
|
156
|
+
# leaks tool calls in two NON-pipe-token shapes when the serving layer fails to
|
|
157
|
+
# emit structured ``tool_calls`` — both captured live:
|
|
158
|
+
# A. Anthropic XML: <invoke name="done"><parameter name="text">hi</parameter></invoke>
|
|
159
|
+
# B. python-call: done(text="hi") (recovered only for KNOWN tool names)
|
|
160
|
+
# The pipe-token path (recover_kimi_content) never sees these. Left unrecovered,
|
|
161
|
+
# the action is LOST (empty-action loop) AND the raw text dumps to the user as a
|
|
162
|
+
# "rob" bubble (the leak shapes end in ``)``/``>`` so the render-layer brain-state
|
|
163
|
+
# check doesn't demote them). recover_textual_tool_calls reconciles both shapes.
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
_XML_INVOKE_RE = re.compile(
|
|
167
|
+
r"<invoke\b[^>]*\bname=\"(?P<name>[\w.\-]+)\"[^>]*>(?P<body>.*?)</invoke>",
|
|
168
|
+
re.DOTALL,
|
|
169
|
+
)
|
|
170
|
+
_XML_PARAM_RE = re.compile(
|
|
171
|
+
r"<parameter\b[^>]*\bname=\"(?P<key>[\w.\-]+)\"[^>]*>(?P<val>.*?)</parameter>",
|
|
172
|
+
re.DOTALL,
|
|
173
|
+
)
|
|
174
|
+
#: Stray/partial function-call XML tags to strip even when no full block parses
|
|
175
|
+
#: (mirrors the WS-2.1 "always strip" invariant for pipe tokens — a lone
|
|
176
|
+
#: ``</invoke>`` must never reach the user).
|
|
177
|
+
_XML_TOOLCALL_TAG_RE = re.compile(
|
|
178
|
+
r"</?(?:function_calls|invoke|parameter)\b[^>]*>", re.DOTALL
|
|
179
|
+
)
|
|
180
|
+
#: GLM/zhipu leak: a whole ``<function_calls> ... </function_calls>`` dump (tool name
|
|
181
|
+
#: as tag + ``<arg_key>``/``<arg_value>`` children, frequently malformed). The args
|
|
182
|
+
#: can't be reliably recovered from the malformed shape, but the block must be
|
|
183
|
+
#: stripped wholesale so raw tool-call XML never reaches the user. Non-greedy to the
|
|
184
|
+
#: first close tag, or to end-of-string when GLM omits/mangles the close.
|
|
185
|
+
_FUNCTION_CALLS_BLOCK_RE = re.compile(
|
|
186
|
+
r"<function_calls\b.*?(?:</function_calls>|$)", re.DOTALL
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _has_toolcall_xml_marker(content: Optional[str]) -> bool:
|
|
191
|
+
"""True if content carries a tool-call XML marker (provider-agnostic leak signal).
|
|
192
|
+
|
|
193
|
+
Prose virtually never contains these literal markers, so their presence is a
|
|
194
|
+
reliable signal to run textual-tool-call recovery/stripping for ANY provider —
|
|
195
|
+
not just Kimi (GLM leaks ``<function_calls>``; Anthropic-style leaks ``<invoke>``).
|
|
196
|
+
"""
|
|
197
|
+
if not content:
|
|
198
|
+
return False
|
|
199
|
+
return "<function_calls" in content or "<invoke" in content
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def tool_names_from_schemas(tools: Optional[List[Dict[str, Any]]]) -> set:
|
|
203
|
+
"""Extract the set of callable tool names from OpenAI-format tool schemas."""
|
|
204
|
+
names: set = set()
|
|
205
|
+
for t in tools or []:
|
|
206
|
+
if not isinstance(t, dict):
|
|
207
|
+
continue
|
|
208
|
+
fn = t.get("function") if isinstance(t.get("function"), dict) else None
|
|
209
|
+
name = (fn or {}).get("name") or t.get("name")
|
|
210
|
+
if isinstance(name, str) and name:
|
|
211
|
+
names.add(name)
|
|
212
|
+
return names
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _coerce_param_value(raw: str) -> Any:
|
|
216
|
+
"""Best-effort coerce an XML ``<parameter>`` body to a JSON value, else str."""
|
|
217
|
+
s = (raw or "").strip()
|
|
218
|
+
try:
|
|
219
|
+
return json.loads(s)
|
|
220
|
+
except (ValueError, TypeError):
|
|
221
|
+
return raw
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _parse_call_args(inner: str) -> Optional[Dict[str, Any]]:
|
|
225
|
+
"""Parse the argument list of a textual ``name(...)`` call.
|
|
226
|
+
|
|
227
|
+
Accepts ``{json}`` (a single JSON object) or ``key=<json-value>`` kwargs
|
|
228
|
+
(the common ``text="..."`` shape). Returns ``None`` when nothing parses, so a
|
|
229
|
+
bare prose ``foo(bar)`` mention isn't misread as a tool call.
|
|
230
|
+
"""
|
|
231
|
+
s = (inner or "").strip()
|
|
232
|
+
if not s:
|
|
233
|
+
return {}
|
|
234
|
+
decoder = json.JSONDecoder()
|
|
235
|
+
if s.startswith("{"):
|
|
236
|
+
try:
|
|
237
|
+
obj, _ = decoder.raw_decode(s)
|
|
238
|
+
if isinstance(obj, dict):
|
|
239
|
+
return obj
|
|
240
|
+
except (ValueError, TypeError):
|
|
241
|
+
return None
|
|
242
|
+
return None
|
|
243
|
+
args: Dict[str, Any] = {}
|
|
244
|
+
kw_re = re.compile(r"\s*(?P<key>[\w.\-]+)\s*=\s*")
|
|
245
|
+
i, n = 0, len(s)
|
|
246
|
+
while i < n:
|
|
247
|
+
m = kw_re.match(s, i)
|
|
248
|
+
if not m:
|
|
249
|
+
break
|
|
250
|
+
key = m.group("key")
|
|
251
|
+
j = m.end()
|
|
252
|
+
if j < n and s[j] in '"[{':
|
|
253
|
+
try:
|
|
254
|
+
val, end = decoder.raw_decode(s, j)
|
|
255
|
+
except (ValueError, TypeError):
|
|
256
|
+
break
|
|
257
|
+
args[key] = val
|
|
258
|
+
i = end
|
|
259
|
+
else:
|
|
260
|
+
k = s.find(",", j)
|
|
261
|
+
if k == -1:
|
|
262
|
+
k = n
|
|
263
|
+
token = s[j:k].strip()
|
|
264
|
+
try:
|
|
265
|
+
args[key] = json.loads(token)
|
|
266
|
+
except (ValueError, TypeError):
|
|
267
|
+
args[key] = token
|
|
268
|
+
i = k
|
|
269
|
+
while i < n and s[i] in ", \n\t":
|
|
270
|
+
i += 1
|
|
271
|
+
return args or None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _find_balanced_paren(text: str, open_idx: int) -> int:
|
|
275
|
+
"""Index of the ``)`` matching the ``(`` at *open_idx*, respecting double-
|
|
276
|
+
quoted strings so a ``)`` inside a string value doesn't close early.
|
|
277
|
+
Returns -1 when unbalanced."""
|
|
278
|
+
depth = 0
|
|
279
|
+
in_str = False
|
|
280
|
+
esc = False
|
|
281
|
+
for i in range(open_idx, len(text)):
|
|
282
|
+
c = text[i]
|
|
283
|
+
if in_str:
|
|
284
|
+
if esc:
|
|
285
|
+
esc = False
|
|
286
|
+
elif c == "\\":
|
|
287
|
+
esc = True
|
|
288
|
+
elif c == '"':
|
|
289
|
+
in_str = False
|
|
290
|
+
continue
|
|
291
|
+
if c == '"':
|
|
292
|
+
in_str = True
|
|
293
|
+
elif c == "(":
|
|
294
|
+
depth += 1
|
|
295
|
+
elif c == ")":
|
|
296
|
+
depth -= 1
|
|
297
|
+
if depth == 0:
|
|
298
|
+
return i
|
|
299
|
+
return -1
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def recover_textual_tool_calls(
|
|
303
|
+
content: str, known_names: Optional[set] = None
|
|
304
|
+
) -> Tuple[str, List[Dict[str, Any]]]:
|
|
305
|
+
"""Recover XML / python-call tool calls leaked as raw text; strip them out.
|
|
306
|
+
|
|
307
|
+
Returns ``(cleaned_content, tool_calls)``; a no-op returns ``(content, [])``.
|
|
308
|
+
Only ``known_names`` are recovered from the ``name(...)`` shape so prose that
|
|
309
|
+
merely mentions a tool isn't misread as a call. The ``name(...)`` scan starts
|
|
310
|
+
AFTER any leading brain-state JSON object, so a tool name quoted inside the
|
|
311
|
+
brain JSON can't trigger a spurious call (or corrupt the JSON on strip).
|
|
312
|
+
"""
|
|
313
|
+
if not content:
|
|
314
|
+
return (content, [])
|
|
315
|
+
found: List[Tuple[int, int, str, Dict[str, Any]]] = []
|
|
316
|
+
|
|
317
|
+
# A. Anthropic <invoke name="...">...</invoke> blocks (whole content).
|
|
318
|
+
for m in _XML_INVOKE_RE.finditer(content):
|
|
319
|
+
body = m.group("body")
|
|
320
|
+
args: Dict[str, Any] = {}
|
|
321
|
+
for pm in _XML_PARAM_RE.finditer(body):
|
|
322
|
+
args[pm.group("key")] = _coerce_param_value(pm.group("val"))
|
|
323
|
+
if not args:
|
|
324
|
+
parsed = _parse_call_args(body)
|
|
325
|
+
if parsed:
|
|
326
|
+
args = parsed
|
|
327
|
+
found.append((m.start(), m.end(), m.group("name"), args))
|
|
328
|
+
|
|
329
|
+
# B. python-call name(...) for KNOWN names — only when no XML block matched,
|
|
330
|
+
# and only over the tail AFTER a leading brain-state JSON object.
|
|
331
|
+
if not found and known_names:
|
|
332
|
+
scan_start = 0
|
|
333
|
+
lead = len(content) - len(content.lstrip())
|
|
334
|
+
tail = content[lead:]
|
|
335
|
+
if tail.startswith("{"):
|
|
336
|
+
try:
|
|
337
|
+
_obj, end = json.JSONDecoder().raw_decode(tail)
|
|
338
|
+
scan_start = lead + end
|
|
339
|
+
except (ValueError, TypeError):
|
|
340
|
+
scan_start = 0
|
|
341
|
+
region = content[scan_start:]
|
|
342
|
+
for name in known_names:
|
|
343
|
+
for m in re.finditer(rf"(?<![\w.]){re.escape(name)}\s*\(", region):
|
|
344
|
+
open_idx = region.index("(", m.start())
|
|
345
|
+
close = _find_balanced_paren(region, open_idx)
|
|
346
|
+
if close == -1:
|
|
347
|
+
continue
|
|
348
|
+
args = _parse_call_args(region[open_idx + 1 : close])
|
|
349
|
+
if args is None:
|
|
350
|
+
continue
|
|
351
|
+
found.append(
|
|
352
|
+
(scan_start + m.start(), scan_start + close + 1, name, args)
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
found.sort(key=lambda f: f[0])
|
|
356
|
+
calls = [
|
|
357
|
+
{
|
|
358
|
+
"id": _new_recovered_call_id("txt", i),
|
|
359
|
+
"type": "function",
|
|
360
|
+
"function": {"name": name, "arguments": json.dumps(args)},
|
|
361
|
+
}
|
|
362
|
+
for i, (_s, _e, name, args) in enumerate(found)
|
|
363
|
+
]
|
|
364
|
+
|
|
365
|
+
cleaned = content
|
|
366
|
+
for start, end, _n, _a in sorted(found, key=lambda f: f[0], reverse=True):
|
|
367
|
+
cleaned = cleaned[:start] + cleaned[end:]
|
|
368
|
+
# GLM/zhipu: strip any whole <function_calls> dump block (incl. its malformed
|
|
369
|
+
# inner tool-name / arg_key / arg_value tags) so raw XML never reaches the user.
|
|
370
|
+
cleaned = _FUNCTION_CALLS_BLOCK_RE.sub("", cleaned)
|
|
371
|
+
# Backstop: strip any stray/partial function-call XML tags left behind.
|
|
372
|
+
cleaned = _XML_TOOLCALL_TAG_RE.sub("", cleaned).strip()
|
|
373
|
+
return (cleaned, calls)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class OpenRouterClient(LLMClient):
|
|
377
|
+
"""OpenRouter LLM client - unified access to Grok, Kimi, Qwen, and more.
|
|
378
|
+
|
|
379
|
+
OpenRouter uses OpenAI-compatible API format, so this client extends
|
|
380
|
+
the patterns from OpenAIClient while adding OpenRouter-specific headers.
|
|
381
|
+
|
|
382
|
+
All model capabilities and limits come from model_registry.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
# OpenRouter API base URL (fallback; the declarative source is ProviderProfile — P8)
|
|
386
|
+
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
387
|
+
|
|
388
|
+
# Human-readable provider name used in log/exception messages. Subclasses that
|
|
389
|
+
# reuse this OpenAI-compatible pipeline (e.g. NvidiaClient) override it so their
|
|
390
|
+
# logs/errors name the real provider instead of "OpenRouter".
|
|
391
|
+
_PROVIDER_LABEL = "OpenRouter"
|
|
392
|
+
|
|
393
|
+
def _profile_base_url(self) -> str:
|
|
394
|
+
"""Transport base URL sourced from the declarative ProviderProfile (P8),
|
|
395
|
+
falling back to the hardcoded constant if the profile is unavailable.
|
|
396
|
+
|
|
397
|
+
Delegates to the base ``_resolve_profile_base_url`` helper (profiles.py
|
|
398
|
+
is the single source of truth); keeps the class-level constant as the
|
|
399
|
+
fallback so no prior call sites need to change.
|
|
400
|
+
"""
|
|
401
|
+
return self._resolve_profile_base_url("openrouter") or self.OPENROUTER_BASE_URL
|
|
402
|
+
|
|
403
|
+
def __init__(self, config: BotConfig, name: str = "openrouter_client"):
|
|
404
|
+
"""Initialize the OpenRouter client."""
|
|
405
|
+
super().__init__(config=config, name=name)
|
|
406
|
+
self._client = None
|
|
407
|
+
|
|
408
|
+
# Get OpenRouter config
|
|
409
|
+
openrouter_config = config.get_llm_config().get('openrouter', {})
|
|
410
|
+
self.api_key = openrouter_config.get('api_key')
|
|
411
|
+
# Default to the registry's SSOT default (P0.7: was a stale hardcoded
|
|
412
|
+
# literal that had drifted from DEFAULT_MODELS['openrouter']). Honors the
|
|
413
|
+
# POLYROB_OPENROUTER_MODEL env override via get_default_model().
|
|
414
|
+
self.model_type = openrouter_config.get('model') or get_default_model('openrouter')
|
|
415
|
+
|
|
416
|
+
# OpenRouter-specific headers for app attribution
|
|
417
|
+
self.site_url = openrouter_config.get('site_url', '')
|
|
418
|
+
self.site_name = openrouter_config.get('site_name', 'POLYROB AI Agent')
|
|
419
|
+
|
|
420
|
+
self.last_response = None
|
|
421
|
+
|
|
422
|
+
# Get model config from registry (single source of truth)
|
|
423
|
+
model_config = get_model_config(self.model_type)
|
|
424
|
+
if model_config:
|
|
425
|
+
self.max_tokens = model_config.max_completion_tokens
|
|
426
|
+
else:
|
|
427
|
+
# Fallback defaults for OpenRouter (Grok defaults)
|
|
428
|
+
self.max_tokens = 32768
|
|
429
|
+
self.logger.warning(f"Model '{self.model_type}' not found in registry, using defaults")
|
|
430
|
+
|
|
431
|
+
# Resolve vision support via base helper (falls back to True if not in registry)
|
|
432
|
+
self.supports_vision = self._resolve_supports_vision()
|
|
433
|
+
|
|
434
|
+
self.temperature = 0.7 # OpenRouter default
|
|
435
|
+
|
|
436
|
+
self.logger.debug(
|
|
437
|
+
f"OpenRouter client initialized: model={self.model_type}, "
|
|
438
|
+
f"max_tokens={self.max_tokens}, supports_vision={self.supports_vision}"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def _supports_tools(self) -> bool:
|
|
442
|
+
"""Check if current model supports tool calling via model_registry."""
|
|
443
|
+
config = get_model_config(self.model_type)
|
|
444
|
+
if config and config.capabilities:
|
|
445
|
+
return config.capabilities.supports_function_calling
|
|
446
|
+
return True # Most OpenRouter models support tools
|
|
447
|
+
|
|
448
|
+
def _validate_llm_config(self) -> None:
|
|
449
|
+
"""Validate LLM config."""
|
|
450
|
+
if not self.api_key:
|
|
451
|
+
raise ServiceError(f"{self._PROVIDER_LABEL} API key not provided")
|
|
452
|
+
|
|
453
|
+
async def _setup_client(self) -> None:
|
|
454
|
+
"""Set up the OpenRouter client using OpenAI SDK."""
|
|
455
|
+
try:
|
|
456
|
+
self._client = AsyncOpenAI(
|
|
457
|
+
api_key=self.api_key,
|
|
458
|
+
base_url=self._profile_base_url(), # P8: profile-sourced transport URL
|
|
459
|
+
default_headers=self._get_openrouter_headers()
|
|
460
|
+
)
|
|
461
|
+
self.logger.debug(f"{self._PROVIDER_LABEL} client setup completed")
|
|
462
|
+
except Exception as e:
|
|
463
|
+
raise ServiceError(f"Failed to set up {self._PROVIDER_LABEL} client: {e}")
|
|
464
|
+
|
|
465
|
+
def _get_openrouter_headers(self) -> Dict[str, str]:
|
|
466
|
+
"""Get OpenRouter-specific headers for app attribution."""
|
|
467
|
+
headers = {}
|
|
468
|
+
if self.site_url:
|
|
469
|
+
headers["HTTP-Referer"] = self.site_url
|
|
470
|
+
if self.site_name:
|
|
471
|
+
headers["X-Title"] = self.site_name
|
|
472
|
+
return headers
|
|
473
|
+
|
|
474
|
+
async def _validate_connection(self) -> None:
|
|
475
|
+
"""Validate OpenRouter connection."""
|
|
476
|
+
try:
|
|
477
|
+
response = await self._client.chat.completions.create(
|
|
478
|
+
model=self.model_type,
|
|
479
|
+
messages=[{"role": "user", "content": "Test"}],
|
|
480
|
+
max_tokens=5
|
|
481
|
+
)
|
|
482
|
+
if not response or not response.choices:
|
|
483
|
+
raise LLMError(f"No valid response from {self._PROVIDER_LABEL} API")
|
|
484
|
+
self.logger.debug(f"{self._PROVIDER_LABEL} API connection validated successfully")
|
|
485
|
+
except Exception as e:
|
|
486
|
+
self.logger.error(f"Failed to validate {self._PROVIDER_LABEL} connection: {e}")
|
|
487
|
+
raise ServiceError(f"Failed to validate {self._PROVIDER_LABEL} connection: {e}")
|
|
488
|
+
|
|
489
|
+
async def _cleanup_client(self) -> None:
|
|
490
|
+
"""Clean up OpenRouter client."""
|
|
491
|
+
if self._client:
|
|
492
|
+
if hasattr(self._client, 'close'):
|
|
493
|
+
await self._client.close()
|
|
494
|
+
self._client = None
|
|
495
|
+
self.logger.debug(f"{self._PROVIDER_LABEL} client resources released")
|
|
496
|
+
|
|
497
|
+
async def _initialize(self) -> None:
|
|
498
|
+
"""Initialize the client."""
|
|
499
|
+
if self._initialized:
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
# Log initialization attempt
|
|
504
|
+
self.logger.info(f"Initializing {self._PROVIDER_LABEL} client with model {self.model_type}")
|
|
505
|
+
|
|
506
|
+
# Validate API key
|
|
507
|
+
self._validate_llm_config()
|
|
508
|
+
|
|
509
|
+
# Setup client
|
|
510
|
+
await self._setup_client()
|
|
511
|
+
|
|
512
|
+
# Test connection (skipped for fast startup; validates lazily)
|
|
513
|
+
if not self._skip_validate:
|
|
514
|
+
await self._validate_connection()
|
|
515
|
+
|
|
516
|
+
# Mark as initialized
|
|
517
|
+
self._initialized = True
|
|
518
|
+
|
|
519
|
+
# Log successful initialization
|
|
520
|
+
self.logger.info(f"✨ {self._PROVIDER_LABEL} client initialized successfully with model {self.model_type}")
|
|
521
|
+
|
|
522
|
+
except Exception as e:
|
|
523
|
+
# Single error log with clear cause
|
|
524
|
+
error_msg = f"Failed to initialize {self._PROVIDER_LABEL} client: {str(e)}"
|
|
525
|
+
self.logger.error(error_msg)
|
|
526
|
+
raise ServiceError(error_msg)
|
|
527
|
+
|
|
528
|
+
async def _generate(
|
|
529
|
+
self,
|
|
530
|
+
messages: List[Dict[str, str]],
|
|
531
|
+
temperature: Optional[float] = None,
|
|
532
|
+
max_tokens: Optional[int] = None,
|
|
533
|
+
**kwargs
|
|
534
|
+
) -> str:
|
|
535
|
+
"""Generate a response from OpenRouter API."""
|
|
536
|
+
start_time = time.time()
|
|
537
|
+
|
|
538
|
+
try:
|
|
539
|
+
if not self._initialized:
|
|
540
|
+
await self._initialize()
|
|
541
|
+
|
|
542
|
+
# Format messages
|
|
543
|
+
openrouter_messages = []
|
|
544
|
+
for msg in messages:
|
|
545
|
+
role = msg.get('role', 'user')
|
|
546
|
+
if role == 'ai':
|
|
547
|
+
role = 'assistant'
|
|
548
|
+
|
|
549
|
+
message_dict = {"role": role, "content": msg.get('content', '')}
|
|
550
|
+
|
|
551
|
+
# Handle tool messages
|
|
552
|
+
if role == 'tool' and 'tool_call_id' in msg:
|
|
553
|
+
message_dict["tool_call_id"] = msg["tool_call_id"]
|
|
554
|
+
if role == 'assistant' and 'tool_calls' in msg:
|
|
555
|
+
message_dict["tool_calls"] = msg["tool_calls"]
|
|
556
|
+
|
|
557
|
+
openrouter_messages.append(message_dict)
|
|
558
|
+
|
|
559
|
+
temp = temperature if temperature is not None else self.temperature
|
|
560
|
+
max_tokens_value = self._adjust_max_tokens(messages, max_tokens)
|
|
561
|
+
|
|
562
|
+
# Make request
|
|
563
|
+
request_params = {
|
|
564
|
+
'model': self.model_type,
|
|
565
|
+
'messages': openrouter_messages,
|
|
566
|
+
'max_tokens': max_tokens_value,
|
|
567
|
+
'temperature': temp
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
self.logger.debug(f"{self._PROVIDER_LABEL} API request: model={self.model_type}, max_tokens={max_tokens_value}")
|
|
571
|
+
|
|
572
|
+
self.last_response = await self._client.chat.completions.create(**request_params)
|
|
573
|
+
|
|
574
|
+
# Extract response
|
|
575
|
+
response_text = ""
|
|
576
|
+
if self.last_response.choices:
|
|
577
|
+
choice = self.last_response.choices[0]
|
|
578
|
+
if choice.message:
|
|
579
|
+
response_text = choice.message.content or ""
|
|
580
|
+
|
|
581
|
+
self._extract_usage_and_capture_telemetry(start_time, True, None, kwargs.get('metadata'))
|
|
582
|
+
return response_text
|
|
583
|
+
|
|
584
|
+
except Exception as e:
|
|
585
|
+
self._extract_usage_and_capture_telemetry(start_time, False, str(e), kwargs.get('metadata'))
|
|
586
|
+
self.logger.error(f"{self._PROVIDER_LABEL} API error: {e}")
|
|
587
|
+
|
|
588
|
+
# Route through the unified classifier; fall back to ServiceError for generic
|
|
589
|
+
# errors (preserving the original observable contract at this call site).
|
|
590
|
+
translated = translate_llm_error(e, self._PROVIDER_LABEL)
|
|
591
|
+
if type(translated) is LLMError:
|
|
592
|
+
raise ServiceError(f"{self._PROVIDER_LABEL} API error: {e}")
|
|
593
|
+
raise translated
|
|
594
|
+
|
|
595
|
+
async def generate_response(
|
|
596
|
+
self,
|
|
597
|
+
prompt: Optional[Union[str, Dict[str, Any]]] = None,
|
|
598
|
+
messages: Optional[List[Dict[str, str]]] = None,
|
|
599
|
+
system: Optional[str] = None,
|
|
600
|
+
temperature: Optional[float] = None,
|
|
601
|
+
max_tokens: Optional[int] = None,
|
|
602
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
603
|
+
**kwargs
|
|
604
|
+
) -> str:
|
|
605
|
+
"""Generate a response from the LLM."""
|
|
606
|
+
try:
|
|
607
|
+
# Handle prompt formats
|
|
608
|
+
if messages is not None:
|
|
609
|
+
formatted_messages = messages
|
|
610
|
+
elif isinstance(prompt, list) and all(isinstance(m, dict) for m in prompt):
|
|
611
|
+
formatted_messages = prompt
|
|
612
|
+
elif isinstance(prompt, dict) and 'messages' in prompt:
|
|
613
|
+
formatted_messages = prompt['messages']
|
|
614
|
+
elif isinstance(prompt, str):
|
|
615
|
+
formatted_messages = [{"role": "user", "content": prompt}]
|
|
616
|
+
else:
|
|
617
|
+
formatted_messages = [{"role": "user", "content": "Hello"}]
|
|
618
|
+
|
|
619
|
+
# Add system message
|
|
620
|
+
if system:
|
|
621
|
+
has_system = any(msg.get('role') == 'system' for msg in formatted_messages)
|
|
622
|
+
if not has_system:
|
|
623
|
+
formatted_messages.insert(0, {"role": "system", "content": system})
|
|
624
|
+
|
|
625
|
+
return await self._generate(
|
|
626
|
+
messages=formatted_messages,
|
|
627
|
+
temperature=temperature,
|
|
628
|
+
max_tokens=max_tokens,
|
|
629
|
+
metadata=metadata,
|
|
630
|
+
**kwargs
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
except Exception as e:
|
|
634
|
+
self.logger.error(f"Failed to generate response: {e}")
|
|
635
|
+
raise ServiceError(f"Failed to generate response: {e}")
|
|
636
|
+
|
|
637
|
+
async def _generate_with_tools(
|
|
638
|
+
self,
|
|
639
|
+
messages: List[Dict[str, Any]],
|
|
640
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
641
|
+
system: Optional[str] = None,
|
|
642
|
+
temperature: Optional[float] = None,
|
|
643
|
+
max_tokens: Optional[int] = None,
|
|
644
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
645
|
+
**kwargs
|
|
646
|
+
) -> Tuple[str, List[Dict[str, Any]], Dict[str, Optional[int]]]:
|
|
647
|
+
"""Generate response with tool calling support.
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
messages: List of messages
|
|
651
|
+
tools: List of tools in OpenAI format
|
|
652
|
+
system: System prompt
|
|
653
|
+
temperature: Temperature for generation
|
|
654
|
+
max_tokens: Maximum tokens for generation
|
|
655
|
+
metadata: Request metadata
|
|
656
|
+
**kwargs: Additional parameters
|
|
657
|
+
|
|
658
|
+
Returns:
|
|
659
|
+
Tuple of (text_response, tool_calls, usage_data)
|
|
660
|
+
"""
|
|
661
|
+
start_time = time.time()
|
|
662
|
+
|
|
663
|
+
try:
|
|
664
|
+
if not self._initialized:
|
|
665
|
+
await self._initialize()
|
|
666
|
+
|
|
667
|
+
# Format messages
|
|
668
|
+
formatted_messages = []
|
|
669
|
+
if system:
|
|
670
|
+
formatted_messages.append({"role": "system", "content": system})
|
|
671
|
+
|
|
672
|
+
for msg in messages:
|
|
673
|
+
formatted_msg = msg.copy()
|
|
674
|
+
role = msg.get('role', 'user')
|
|
675
|
+
if role == 'ai':
|
|
676
|
+
formatted_msg['role'] = 'assistant'
|
|
677
|
+
|
|
678
|
+
# Convert tool calls to OpenAI format if needed
|
|
679
|
+
if msg.get("role") == "assistant" and "tool_calls" in msg:
|
|
680
|
+
tool_calls = []
|
|
681
|
+
for tc in msg.get("tool_calls", []):
|
|
682
|
+
if not isinstance(tc.get("function"), dict):
|
|
683
|
+
if "name" in tc and "args" in tc:
|
|
684
|
+
tool_calls.append({
|
|
685
|
+
"id": tc["id"],
|
|
686
|
+
"type": "function",
|
|
687
|
+
"function": {
|
|
688
|
+
"name": tc["name"],
|
|
689
|
+
"arguments": json.dumps(tc["args"]) if not isinstance(tc.get("args"), str) else tc["args"]
|
|
690
|
+
}
|
|
691
|
+
})
|
|
692
|
+
else:
|
|
693
|
+
tool_calls.append(tc)
|
|
694
|
+
else:
|
|
695
|
+
tool_calls.append(tc)
|
|
696
|
+
formatted_msg["tool_calls"] = tool_calls
|
|
697
|
+
formatted_messages.append(formatted_msg)
|
|
698
|
+
|
|
699
|
+
temp = temperature if temperature is not None else self.temperature
|
|
700
|
+
max_tokens_value = self._adjust_max_tokens(messages, max_tokens)
|
|
701
|
+
|
|
702
|
+
# P1-3: mark the stable system prefix cacheable for breakpoint-style models
|
|
703
|
+
# (Anthropic/Gemini via OpenRouter). No-op unless OPENROUTER_PROMPT_CACHE is on.
|
|
704
|
+
from modules.llm.cache_hints import apply_openrouter_cache_control
|
|
705
|
+
formatted_messages = apply_openrouter_cache_control(formatted_messages, self.model_type)
|
|
706
|
+
|
|
707
|
+
request_params = {
|
|
708
|
+
'model': self.model_type,
|
|
709
|
+
'messages': formatted_messages,
|
|
710
|
+
'max_tokens': max_tokens_value,
|
|
711
|
+
'temperature': temp
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if tools:
|
|
715
|
+
# Ensure tools is a list (not dict) in OpenAI format
|
|
716
|
+
if isinstance(tools, dict):
|
|
717
|
+
self.logger.warning(f"Tools passed as dict, expected list. This may indicate a schema generator issue.")
|
|
718
|
+
# Try to extract list if it's wrapped in a dict
|
|
719
|
+
if 'function_declarations' in tools:
|
|
720
|
+
# Gemini format - convert to OpenAI format
|
|
721
|
+
tools = [{"type": "function", "function": f} for f in tools['function_declarations']]
|
|
722
|
+
else:
|
|
723
|
+
# Unknown dict format - skip tools
|
|
724
|
+
self.logger.error(f"Cannot convert tools dict to list: {list(tools.keys())}")
|
|
725
|
+
tools = None
|
|
726
|
+
|
|
727
|
+
if tools:
|
|
728
|
+
# UP-08: extend the cache breakpoint over the tools block for
|
|
729
|
+
# breakpoint-style models (no-op unless OPENROUTER_PROMPT_CACHE on).
|
|
730
|
+
from modules.llm.cache_hints import apply_openrouter_tools_cache_control
|
|
731
|
+
tools = apply_openrouter_tools_cache_control(tools, self.model_type)
|
|
732
|
+
request_params['tools'] = tools
|
|
733
|
+
request_params['tool_choice'] = 'auto'
|
|
734
|
+
|
|
735
|
+
self.logger.info(f"[_generate_with_tools] Starting API request: model={self.model_type}, tools={len(tools) if tools else 0}, messages={len(formatted_messages)}")
|
|
736
|
+
|
|
737
|
+
api_start = time.time()
|
|
738
|
+
response = await self._client.chat.completions.create(**request_params)
|
|
739
|
+
api_duration = time.time() - api_start
|
|
740
|
+
|
|
741
|
+
self.last_response = response
|
|
742
|
+
self.logger.info(f"[_generate_with_tools] API request completed in {api_duration:.1f}s")
|
|
743
|
+
|
|
744
|
+
message = response.choices[0].message
|
|
745
|
+
usage_data = self._extract_usage_data()
|
|
746
|
+
|
|
747
|
+
if hasattr(message, 'tool_calls') and message.tool_calls:
|
|
748
|
+
content = message.content or ""
|
|
749
|
+
tool_calls_list = []
|
|
750
|
+
for tc in message.tool_calls:
|
|
751
|
+
# Log raw arguments for debugging MCP nested args issue
|
|
752
|
+
raw_args = tc.function.arguments
|
|
753
|
+
self.logger.info(f"[RAW_TOOL_CALL] {tc.function.name}: arguments type={type(raw_args).__name__}, value={raw_args[:500] if isinstance(raw_args, str) else raw_args}")
|
|
754
|
+
|
|
755
|
+
tool_calls_list.append({
|
|
756
|
+
"id": tc.id,
|
|
757
|
+
"type": tc.type,
|
|
758
|
+
"function": {
|
|
759
|
+
"name": tc.function.name,
|
|
760
|
+
"arguments": raw_args
|
|
761
|
+
}
|
|
762
|
+
})
|
|
763
|
+
self.logger.debug(f"Extracted {len(tool_calls_list)} tool calls from response")
|
|
764
|
+
return (content, tool_calls_list, usage_data)
|
|
765
|
+
|
|
766
|
+
# Fallback: serving layers (notably NVIDIA NIM with Kimi-K2)
|
|
767
|
+
# intermittently fail to parse Kimi's native tool-call tokens and
|
|
768
|
+
# return them as raw text in content with no structured tool_calls.
|
|
769
|
+
# Recover any calls AND always strip the tokens (WS-2.1) so the agent
|
|
770
|
+
# executes the actions (instead of wasting a planning turn) and the
|
|
771
|
+
# control tokens never leak — even in the stray-end-token shape that
|
|
772
|
+
# recovers 0 calls.
|
|
773
|
+
raw_content = message.content or ""
|
|
774
|
+
# L3 FIX: only run Kimi token recovery for Kimi models. The marker
|
|
775
|
+
# ("<|tool_call") is only ever leaked by Kimi-K2 on NIM; gating on the
|
|
776
|
+
# model means a non-Kimi OpenRouter model (Grok/Qwen/…) that legitimately
|
|
777
|
+
# quotes that literal in prose isn't silently truncated.
|
|
778
|
+
cleaned_content, recovered = raw_content, []
|
|
779
|
+
if "kimi" in (self.model_type or "").lower():
|
|
780
|
+
cleaned_content, recovered = recover_kimi_content(raw_content)
|
|
781
|
+
# B4 + GLM leak: also recover/strip tool calls leaked as <invoke> XML,
|
|
782
|
+
# GLM <function_calls> dumps, or done(...) python-call syntax. The pipe
|
|
783
|
+
# pass (Kimi-only above) handles <|tool_call| tokens; this handles XML.
|
|
784
|
+
# Marker-gated (provider-agnostic): a <function_calls>/<invoke> marker in
|
|
785
|
+
# content is a reliable leak signal that prose ~never contains — so GLM
|
|
786
|
+
# (z-ai) leaks are now recovered/stripped too, not just Kimi. No marker =>
|
|
787
|
+
# untouched (byte-identical for the common path).
|
|
788
|
+
if not recovered and (
|
|
789
|
+
_has_toolcall_xml_marker(cleaned_content)
|
|
790
|
+
or "kimi" in (self.model_type or "").lower()
|
|
791
|
+
):
|
|
792
|
+
cleaned_content, textual = recover_textual_tool_calls(
|
|
793
|
+
cleaned_content, tool_names_from_schemas(tools)
|
|
794
|
+
)
|
|
795
|
+
recovered = textual
|
|
796
|
+
if cleaned_content != raw_content or recovered:
|
|
797
|
+
# R4: emit a structured, greppable breadcrumb so a Kimi recovery is
|
|
798
|
+
# observable in telemetry/logs at PARSE time (the structured-provider
|
|
799
|
+
# path has no equivalent silent step). Stable marker: kimi_toolcall_recovery.
|
|
800
|
+
if recovered:
|
|
801
|
+
self.logger.warning(
|
|
802
|
+
f"event=kimi_toolcall_recovery model={self.model_type} "
|
|
803
|
+
f"recovered_calls={len(recovered)} action=recovered "
|
|
804
|
+
f"({self._PROVIDER_LABEL}: recovered Kimi tool call(s) from unparsed "
|
|
805
|
+
f"content tokens — serving-layer parser miss)"
|
|
806
|
+
)
|
|
807
|
+
else:
|
|
808
|
+
self.logger.warning(
|
|
809
|
+
f"event=kimi_toolcall_recovery model={self.model_type} "
|
|
810
|
+
f"recovered_calls=0 action=stripped_only "
|
|
811
|
+
f"({self._PROVIDER_LABEL}: stripped leaked Kimi control token(s) with no "
|
|
812
|
+
f"recoverable tool calls — serving-layer parser miss)"
|
|
813
|
+
)
|
|
814
|
+
return (cleaned_content, recovered, usage_data)
|
|
815
|
+
|
|
816
|
+
except Exception as e:
|
|
817
|
+
self._extract_usage_and_capture_telemetry(start_time, False, str(e), metadata)
|
|
818
|
+
self.logger.error(f"{self._PROVIDER_LABEL} tool generation failed: {e}")
|
|
819
|
+
raise LLMError(f"{self._PROVIDER_LABEL} generation failed: {e}")
|
|
820
|
+
|
|
821
|
+
async def generate_agent_response(
|
|
822
|
+
self,
|
|
823
|
+
messages: List[Dict[str, Any]],
|
|
824
|
+
tools: List[Dict[str, Any]],
|
|
825
|
+
**kwargs
|
|
826
|
+
) -> Union[Tuple[str, List[Dict[str, Any]], Dict[str, Optional[int]]], str]:
|
|
827
|
+
"""Generate response with tool-calling support for agents.
|
|
828
|
+
|
|
829
|
+
This method bridges the adapter contract to enable native tool-calling
|
|
830
|
+
for OpenRouter models. It delegates to _generate_with_tools which handles the
|
|
831
|
+
wire-format conversion and API interaction.
|
|
832
|
+
|
|
833
|
+
Args:
|
|
834
|
+
messages: List of message dictionaries
|
|
835
|
+
tools: List of tool schemas in OpenAI-compatible format
|
|
836
|
+
**kwargs: Additional generation parameters
|
|
837
|
+
- system: System prompt (CRITICAL for brain state instructions)
|
|
838
|
+
- temperature: Temperature setting
|
|
839
|
+
- max_tokens: Max completion tokens
|
|
840
|
+
- metadata: Request metadata
|
|
841
|
+
|
|
842
|
+
Returns:
|
|
843
|
+
Either:
|
|
844
|
+
- Tuple of (content, tool_calls, usage_data) when tool calls are present
|
|
845
|
+
usage_data dict contains: prompt_tokens, completion_tokens, total_tokens
|
|
846
|
+
- Just the content string when no tool calls
|
|
847
|
+
|
|
848
|
+
Raises:
|
|
849
|
+
LLMError: If generation fails
|
|
850
|
+
NotImplementedError: If model doesn't support tool calling
|
|
851
|
+
"""
|
|
852
|
+
try:
|
|
853
|
+
self.logger.info(f"[generate_agent_response] Called with {len(messages)} messages and {len(tools) if tools else 0} tools")
|
|
854
|
+
|
|
855
|
+
# Extract system message if present in kwargs
|
|
856
|
+
system = kwargs.pop('system', None)
|
|
857
|
+
temperature = kwargs.pop('temperature', None)
|
|
858
|
+
max_tokens = kwargs.pop('max_tokens', None)
|
|
859
|
+
|
|
860
|
+
# Check if this model supports tool calling (uses model_registry as source of truth)
|
|
861
|
+
if not self._supports_tools():
|
|
862
|
+
self.logger.warning(
|
|
863
|
+
f"OpenRouter model {self.model_type} does not support native tool calling. "
|
|
864
|
+
"Raising NotImplementedError to trigger fallback."
|
|
865
|
+
)
|
|
866
|
+
raise NotImplementedError(
|
|
867
|
+
f"OpenRouter model {self.model_type} does not support native tool/function calling"
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
# Call the tool-enabled generation method
|
|
871
|
+
content, tool_calls, usage_data = await self._generate_with_tools(
|
|
872
|
+
messages=messages,
|
|
873
|
+
tools=tools,
|
|
874
|
+
system=system,
|
|
875
|
+
temperature=temperature,
|
|
876
|
+
max_tokens=max_tokens,
|
|
877
|
+
**kwargs
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
self.logger.info(f"[generate_agent_response] Returning {len(tool_calls)} tool calls + usage")
|
|
881
|
+
|
|
882
|
+
# Always return the 3-tuple (Bug 2). Tool-free conversational/planning
|
|
883
|
+
# turns (and every 0-recovered-call Kimi turn) previously returned a
|
|
884
|
+
# bare content string, dropping message-level usage/billing telemetry.
|
|
885
|
+
# tool_calls may be an empty list; OpenAI/Gemini clients already do this.
|
|
886
|
+
return (content, tool_calls, usage_data)
|
|
887
|
+
|
|
888
|
+
except NotImplementedError:
|
|
889
|
+
# Re-raise NotImplementedError to trigger fallback
|
|
890
|
+
raise
|
|
891
|
+
except Exception as e:
|
|
892
|
+
self.logger.error(f"Error in generate_agent_response: {e}", exc_info=True)
|
|
893
|
+
raise LLMError(f"Failed to generate agent response: {e}")
|
|
894
|
+
|
|
895
|
+
async def validate(self) -> bool:
|
|
896
|
+
"""Validate the OpenRouter client by making a test request."""
|
|
897
|
+
try:
|
|
898
|
+
if not self._client:
|
|
899
|
+
await self._setup_client()
|
|
900
|
+
|
|
901
|
+
response = await self._client.chat.completions.create(
|
|
902
|
+
model=self.model_type,
|
|
903
|
+
messages=[{"role": "user", "content": "test"}],
|
|
904
|
+
max_tokens=1
|
|
905
|
+
)
|
|
906
|
+
if not response or not response.choices:
|
|
907
|
+
raise ValueError(f"Invalid response from {self._PROVIDER_LABEL}")
|
|
908
|
+
self.logger.info(f"{self._PROVIDER_LABEL} client validated with model {self.model_type}")
|
|
909
|
+
return True
|
|
910
|
+
except Exception as e:
|
|
911
|
+
self.logger.error(f"{self._PROVIDER_LABEL} validation failed: {e}")
|
|
912
|
+
# Use similar error translation as DeepSeek
|
|
913
|
+
error_str = str(e).lower()
|
|
914
|
+
if "rate" in error_str or "429" in error_str:
|
|
915
|
+
raise ValueError(f"{self._PROVIDER_LABEL} API rate limit exceeded. Please try again later.")
|
|
916
|
+
elif "key" in error_str or "auth" in error_str or "401" in error_str:
|
|
917
|
+
raise ValueError(f"Invalid {self._PROVIDER_LABEL} API key.")
|
|
918
|
+
else:
|
|
919
|
+
raise ValueError(f"{self._PROVIDER_LABEL} validation failed: {str(e)}")
|
|
920
|
+
|
|
921
|
+
async def _make_validation_request(self) -> Any:
|
|
922
|
+
"""Make a validation request to OpenRouter API.
|
|
923
|
+
|
|
924
|
+
Required abstract method implementation for LLMClient base class.
|
|
925
|
+
"""
|
|
926
|
+
return await self._client.chat.completions.create(
|
|
927
|
+
model=self.model_type,
|
|
928
|
+
messages=[{"role": "user", "content": "test"}],
|
|
929
|
+
max_tokens=1
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
def _check_validation_response(self, response: Any) -> None:
|
|
933
|
+
"""Check validation response from OpenRouter API.
|
|
934
|
+
|
|
935
|
+
Required abstract method implementation for LLMClient base class.
|
|
936
|
+
|
|
937
|
+
Args:
|
|
938
|
+
response: Response from _make_validation_request
|
|
939
|
+
|
|
940
|
+
Raises:
|
|
941
|
+
LLMError: If response is invalid
|
|
942
|
+
"""
|
|
943
|
+
if not response or not response.choices:
|
|
944
|
+
raise LLMError(f"Invalid response from {self._PROVIDER_LABEL} API - no choices returned")
|
|
945
|
+
|
|
946
|
+
def _extract_usage_data(self) -> Dict[str, Optional[int]]:
|
|
947
|
+
"""Extract usage data from response."""
|
|
948
|
+
if not self.last_response or not hasattr(self.last_response, 'usage'):
|
|
949
|
+
return {'prompt_tokens': None, 'completion_tokens': None, 'total_tokens': None}
|
|
950
|
+
|
|
951
|
+
usage = self.last_response.usage
|
|
952
|
+
# UP-08: surface server-side prefix-cache hits (mirrors OpenAIClient). Benefits
|
|
953
|
+
# NIM too (NvidiaClient subclasses this). cached_tokens feeds cached_input_price.
|
|
954
|
+
cached_tokens = None
|
|
955
|
+
details = getattr(usage, 'prompt_tokens_details', None)
|
|
956
|
+
if details is not None:
|
|
957
|
+
cached_tokens = getattr(details, 'cached_tokens', None)
|
|
958
|
+
return {
|
|
959
|
+
'prompt_tokens': getattr(usage, 'prompt_tokens', None),
|
|
960
|
+
'completion_tokens': getattr(usage, 'completion_tokens', None),
|
|
961
|
+
'total_tokens': getattr(usage, 'total_tokens', None),
|
|
962
|
+
'cached_tokens': cached_tokens or 0,
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async def cleanup(self) -> None:
|
|
966
|
+
"""Clean up resources."""
|
|
967
|
+
await self._cleanup_client()
|
|
968
|
+
self._initialized = False
|
|
969
|
+
self.logger.info(f"{self._PROVIDER_LABEL} client cleaned up")
|