machinaos 0.0.76 → 0.0.78
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.
- package/README.md +143 -107
- package/client/dist/assets/ActionBar-Du2MSFSz.js +1 -0
- package/client/dist/assets/ApiKeyInput-k2LBmBjb.js +1 -0
- package/client/dist/assets/ApiKeyPanel-C_bV9U0X.js +1 -0
- package/client/dist/assets/ApiUsageSection-CmVfwZzL.js +1 -0
- package/client/dist/assets/EmailPanel-CeKIMGu-.js +1 -0
- package/client/dist/assets/OAuthPanel-KA3t3Q2K.js +1 -0
- package/client/dist/assets/QrPairingPanel-NgNpJNuk.js +1 -0
- package/client/dist/assets/RateLimitSection-Du5YNVIA.js +1 -0
- package/client/dist/assets/StatusCard-DNLyayXc.js +1 -0
- package/client/dist/assets/index-DQ0nwhec.js +257 -0
- package/client/dist/assets/index-DxmbVskS.css +1 -0
- package/client/dist/assets/vendor-flow-CZmBvHRo.js +1 -0
- package/client/dist/assets/vendor-icons-CVrPjN2Q.js +22 -0
- package/client/dist/assets/vendor-markdown-CRou3yQ5.js +62 -0
- package/client/dist/assets/vendor-misc-C4VxKHs5.js +1 -0
- package/client/dist/assets/vendor-query-SzWcOU0G.js +1 -0
- package/client/dist/assets/vendor-radix-Dnos29jG.js +56 -0
- package/client/dist/assets/vendor-react-DvWIbVx0.js +1 -0
- package/client/dist/index.html +37 -3
- package/client/index.html +28 -1
- package/client/package.json +44 -40
- package/client/src/App.tsx +2 -0
- package/client/src/Dashboard.tsx +157 -45
- package/client/src/ParameterPanel.tsx +3 -5
- package/client/src/adapters/nodeSpecToDescription.ts +1 -0
- package/client/src/assets/icons/NodeIcon.tsx +32 -0
- package/client/src/assets/icons/index.ts +4 -0
- package/client/src/assets/icons/stripe.svg +1 -0
- package/client/src/assets/icons/themedGlyphs.ts +404 -0
- package/client/src/components/AIAgentNode.tsx +77 -53
- package/client/src/components/GenericNode.tsx +34 -52
- package/client/src/components/OutputPanel.tsx +64 -147
- package/client/src/components/ParameterRenderer.tsx +5 -3
- package/client/src/components/SkillEditorModal.tsx +9 -18
- package/client/src/components/SquareNode.tsx +97 -115
- package/client/src/components/StartNode.tsx +32 -42
- package/client/src/components/SvgFilterDefs.tsx +54 -0
- package/client/src/components/TeamMonitorNode.tsx +12 -14
- package/client/src/components/ToolkitNode.tsx +35 -60
- package/client/src/components/TriggerNode.tsx +43 -77
- package/client/src/components/__tests__/CredentialsModal.test.tsx +49 -45
- package/client/src/components/credentials/CredentialsModal.tsx +98 -30
- package/client/src/components/credentials/CredentialsPalette.tsx +73 -5
- package/client/src/components/credentials/catalogueAdapter.ts +17 -1
- package/client/src/components/credentials/panels/ApiKeyPanel.tsx +102 -37
- package/client/src/components/credentials/panels/EmailPanel.tsx +7 -19
- package/client/src/components/credentials/panels/OAuthPanel.tsx +5 -1
- package/client/src/components/credentials/panels/QrPairingPanel.tsx +1 -3
- package/client/src/components/credentials/primitives/ActionBar.tsx +7 -11
- package/client/src/components/credentials/primitives/OAuthConnect.tsx +19 -28
- package/client/src/components/credentials/sections/ProviderDefaultsSection.tsx +24 -3
- package/client/src/components/credentials/types.ts +12 -2
- package/client/src/components/credentials/useCredentialPanel.ts +43 -19
- package/client/src/components/icons/AIProviderIcons.tsx +16 -0
- package/client/src/components/onboarding/OnboardingWizard.tsx +23 -63
- package/client/src/components/onboarding/nodeRoleClasses.ts +23 -0
- package/client/src/components/onboarding/steps/CanvasStep.tsx +15 -21
- package/client/src/components/onboarding/steps/ConceptsStep.tsx +2 -11
- package/client/src/components/onboarding/steps/GetStartedStep.tsx +2 -10
- package/client/src/components/parameterPanel/InputSection.tsx +9 -7
- package/client/src/components/parameterPanel/MasterSkillEditor.tsx +84 -198
- package/client/src/components/parameterPanel/MiddleSection.tsx +57 -80
- package/client/src/components/parameterPanel/ToolSchemaEditor.tsx +31 -25
- package/client/src/components/parameterPanel/__tests__/InputSection.test.tsx +7 -2
- package/client/src/components/ui/AIResultModal.tsx +1 -1
- package/client/src/components/ui/CollapsibleSection.tsx +9 -5
- package/client/src/components/ui/CommandPalette.tsx +147 -0
- package/client/src/components/ui/CommandPaletteHost.tsx +189 -0
- package/client/src/components/ui/ComponentItem.tsx +13 -7
- package/client/src/components/ui/ComponentPalette.tsx +24 -13
- package/client/src/components/ui/ConsolePanel.tsx +19 -11
- package/client/src/components/ui/DropCap.tsx +28 -0
- package/client/src/components/ui/EditableNodeLabel.tsx +10 -2
- package/client/src/components/ui/InputNodesPanel.tsx +1 -1
- package/client/src/components/ui/Modal.tsx +38 -6
- package/client/src/components/ui/OutputDisplayPanel.tsx +1 -1
- package/client/src/components/ui/SettingsPanel.tsx +42 -13
- package/client/src/components/ui/StatusBar.tsx +108 -0
- package/client/src/components/ui/ThemeSwitcher.tsx +109 -0
- package/client/src/components/ui/TopToolbar.tsx +42 -25
- package/client/src/components/ui/WorkflowSidebar.tsx +32 -16
- package/client/src/components/ui/action-button.tsx +40 -15
- package/client/src/components/ui/button.tsx +24 -1
- package/client/src/components/ui/dropdown-menu.tsx +24 -2
- package/client/src/components/ui/input.tsx +19 -2
- package/client/src/components/ui/select.tsx +15 -0
- package/client/src/components/ui/textarea.tsx +15 -2
- package/client/src/contexts/AuthContext.tsx +148 -109
- package/client/src/contexts/ThemeContext.tsx +93 -17
- package/client/src/contexts/WebSocketContext.tsx +373 -206
- package/client/src/contexts/__tests__/AuthContext.test.tsx +221 -0
- package/client/src/hooks/__tests__/useDragVariable.test.ts +7 -1
- package/client/src/hooks/__tests__/useWorkflowOpsListener.test.ts +142 -0
- package/client/src/hooks/useAppTheme.ts +209 -7
- package/client/src/hooks/useAutoSkillEdges.ts +7 -2
- package/client/src/hooks/useCatalogueQuery.ts +67 -1
- package/client/src/hooks/useDragVariable.ts +1 -1
- package/client/src/hooks/useNodeAllowlist.ts +115 -8
- package/client/src/hooks/useOnboarding.ts +20 -8
- package/client/src/hooks/useParameterPanel.ts +2 -1
- package/client/src/hooks/useReactFlowNodes.ts +2 -1
- package/client/src/hooks/useSound.ts +185 -0
- package/client/src/hooks/useWorkflowManagement.ts +6 -8
- package/client/src/hooks/useWorkflowOpsListener.ts +90 -0
- package/client/src/index.css +65 -3
- package/client/src/lib/__tests__/connectionConfig.test.ts +91 -0
- package/client/src/lib/aiModelProviders.ts +8 -0
- package/client/src/lib/connectionConfig.ts +107 -0
- package/client/src/lib/queryPersist.ts +13 -5
- package/client/src/lib/sound.ts +393 -0
- package/client/src/main.tsx +20 -0
- package/client/src/store/useAppStore.ts +26 -0
- package/client/src/styles/canvasAnimations.ts +37 -36
- package/client/src/styles/theme.ts +36 -20
- package/client/src/test/setup.ts +1 -0
- package/client/src/themes/atomic.css +253 -0
- package/client/src/themes/base.css +373 -0
- package/client/src/themes/cyber.css +890 -0
- package/client/src/themes/dark.css +70 -0
- package/client/src/themes/edo.css +246 -0
- package/client/src/themes/greek.css +293 -0
- package/client/src/themes/light.css +78 -0
- package/client/src/themes/plague.css +253 -0
- package/client/src/themes/renaissance.css +727 -0
- package/client/src/themes/rot.css +249 -0
- package/client/src/themes/steampunk.css +272 -0
- package/client/src/themes/surveillance.css +289 -0
- package/client/src/themes/wasteland.css +250 -0
- package/client/src/types/INodeProperties.ts +5 -0
- package/client/src/types/NodeTypes.ts +11 -1
- package/client/src/types/__tests__/cloudEvents.test.ts +99 -0
- package/client/src/types/cloudEvents.ts +78 -0
- package/client/src/vite-env.d.ts +7 -0
- package/client/tsconfig.json +1 -1
- package/client/vite.config.js +62 -2
- package/install.ps1 +1 -1
- package/install.sh +1 -1
- package/machina/commands/build.py +51 -7
- package/machina/pyproject.toml +4 -0
- package/machina/supervisor.py +12 -2
- package/machina/tree.py +71 -21
- package/package.json +4 -4
- package/scripts/install.js +16 -1
- package/server/config/ai_cli_providers.json +54 -0
- package/server/config/credential_providers.json +109 -2
- package/server/config/llm_defaults.json +24 -0
- package/server/config/model_registry.json +338 -499
- package/server/config/node_allowlist.json +16 -1
- package/server/config/pricing.json +8 -0
- package/server/constants.py +38 -15
- package/server/core/container.py +2 -2
- package/server/core/credentials_database.py +35 -2
- package/server/core/logging.py +4 -3
- package/server/main.py +99 -13
- package/server/models/node_metadata.py +1 -0
- package/server/nodejs/package.json +8 -6
- package/server/nodejs/src/index.ts +22 -5
- package/server/nodes/README.md +31 -4
- package/server/nodes/agent/_inline.py +2 -0
- package/server/nodes/agent/_specialized.py +6 -3
- package/server/nodes/agent/ai_agent.py +13 -3
- package/server/nodes/agent/chat_agent.py +6 -3
- package/server/nodes/agent/claude_code_agent.py +287 -75
- package/server/nodes/agent/codex_agent.py +239 -0
- package/server/nodes/agent/deep_agent.py +3 -3
- package/server/nodes/agent/rlm_agent.py +3 -3
- package/server/nodes/android/__init__.py +31 -1
- package/server/nodes/android/_base.py +9 -5
- package/server/{services/android_service.py → nodes/android/_dispatcher.py} +2 -2
- package/server/nodes/android/_handlers.py +154 -0
- package/server/nodes/android/_option_loaders.py +44 -0
- package/server/nodes/android/_refresh.py +127 -0
- package/server/{services/android → nodes/android/_relay}/client.py +4 -4
- package/server/{routers/android.py → nodes/android/_router.py} +27 -8
- package/server/nodes/browser/browser.py +2 -2
- package/server/nodes/code/_base.py +6 -2
- package/server/nodes/code/_claude_code.py +134 -0
- package/server/nodes/document/embedding_generator.py +3 -3
- package/server/nodes/document/http_scraper.py +3 -3
- package/server/nodes/document/vector_store.py +5 -5
- package/server/nodes/email/__init__.py +11 -1
- package/server/nodes/email/_filters.py +21 -0
- package/server/{services/himalaya_service.py → nodes/email/_himalaya.py} +6 -10
- package/server/{services/email_service.py → nodes/email/_service.py} +9 -13
- package/server/nodes/email/email_read.py +1 -1
- package/server/nodes/email/email_receive.py +54 -5
- package/server/nodes/email/email_send.py +1 -1
- package/server/nodes/filesystem/shell.py +24 -1
- package/server/nodes/google/__init__.py +55 -1
- package/server/{services/handlers/google_auth.py → nodes/google/_auth_helper.py} +8 -5
- package/server/nodes/google/_base.py +2 -2
- package/server/nodes/google/_credentials.py +5 -5
- package/server/nodes/google/_filters.py +25 -0
- package/server/nodes/google/_handlers.py +57 -0
- package/server/{services/google_oauth.py → nodes/google/_oauth.py} +195 -162
- package/server/nodes/google/_option_loaders.py +107 -0
- package/server/nodes/google/_refresh.py +66 -0
- package/server/nodes/google/_router.py +131 -0
- package/server/nodes/google/gmail_receive.py +41 -4
- package/server/nodes/groups.py +1 -0
- package/server/nodes/location/_credentials.py +45 -1
- package/server/{services/maps.py → nodes/location/_service.py} +18 -3
- package/server/nodes/location/gmaps_create.py +4 -4
- package/server/nodes/location/gmaps_locations.py +4 -4
- package/server/nodes/location/gmaps_nearby_places.py +4 -4
- package/server/nodes/model/_base.py +8 -3
- package/server/nodes/model/_credentials.py +96 -8
- package/server/nodes/model/_local_validator.py +345 -0
- package/server/nodes/model/lmstudio_chat_model.py +23 -0
- package/server/nodes/model/ollama_chat_model.py +25 -0
- package/server/nodes/proxy/_usage.py +2 -2
- package/server/nodes/proxy/proxy_config.py +14 -14
- package/server/nodes/proxy/proxy_request.py +4 -4
- package/server/nodes/scraper/_credentials.py +29 -1
- package/server/nodes/scraper/apify_actor.py +9 -9
- package/server/nodes/scraper/crawlee_scraper.py +5 -5
- package/server/nodes/search/brave_search.py +4 -0
- package/server/nodes/search/perplexity_search.py +9 -0
- package/server/nodes/search/serper_search.py +3 -0
- package/server/nodes/skill/simple_memory.py +12 -0
- package/server/nodes/social/_base.py +2 -2
- package/server/nodes/stripe/__init__.py +46 -0
- package/server/nodes/stripe/_credentials.py +33 -0
- package/server/nodes/stripe/_handlers.py +270 -0
- package/server/nodes/stripe/_install.py +127 -0
- package/server/nodes/stripe/_source.py +174 -0
- package/server/nodes/stripe/stripe_action.py +81 -0
- package/server/nodes/stripe/stripe_receive.py +92 -0
- package/server/nodes/telegram/_credentials.py +52 -1
- package/server/nodes/telegram/_handlers.py +19 -18
- package/server/nodes/telegram/_service.py +134 -32
- package/server/nodes/telegram/telegram_send.py +5 -6
- package/server/nodes/text/file_handler.py +2 -2
- package/server/nodes/text/text_generator.py +2 -2
- package/server/nodes/tool/agent_builder.py +630 -0
- package/server/nodes/tool/task_manager.py +144 -2
- package/server/nodes/twitter/__init__.py +38 -1
- package/server/nodes/twitter/_base.py +7 -7
- package/server/nodes/twitter/_credentials.py +1 -1
- package/server/nodes/twitter/_filters.py +37 -0
- package/server/nodes/twitter/_handlers.py +77 -0
- package/server/nodes/twitter/_oauth.py +124 -0
- package/server/nodes/twitter/_refresh.py +78 -0
- package/server/nodes/twitter/_router.py +29 -0
- package/server/nodes/twitter/twitter_receive.py +4 -0
- package/server/nodes/visuals.json +64 -19
- package/server/nodes/whatsapp/__init__.py +45 -5
- package/server/nodes/whatsapp/_base.py +3 -3
- package/server/nodes/whatsapp/_filters.py +137 -0
- package/server/nodes/whatsapp/_handlers.py +167 -0
- package/server/nodes/whatsapp/_option_loaders.py +68 -0
- package/server/nodes/whatsapp/_refresh.py +62 -0
- package/server/nodes/whatsapp/_runtime.py +1 -1
- package/server/pyproject.toml +29 -7
- package/server/routers/schemas.py +2 -2
- package/server/routers/webhook.py +26 -9
- package/server/routers/websocket.py +149 -810
- package/server/services/ai.py +89 -8
- package/server/services/auth.py +220 -43
- package/server/services/claude_oauth.py +126 -100
- package/server/services/cli_agent/__init__.py +78 -0
- package/server/services/cli_agent/_handlers.py +237 -0
- package/server/services/cli_agent/config.py +112 -0
- package/server/services/cli_agent/factory.py +48 -0
- package/server/services/cli_agent/lockfile.py +141 -0
- package/server/services/cli_agent/mcp_server.py +482 -0
- package/server/services/cli_agent/protocol.py +173 -0
- package/server/services/cli_agent/providers/__init__.py +9 -0
- package/server/services/cli_agent/providers/anthropic_claude.py +419 -0
- package/server/services/cli_agent/providers/google_gemini.py +80 -0
- package/server/services/cli_agent/providers/openai_codex.py +310 -0
- package/server/services/cli_agent/service.py +607 -0
- package/server/services/cli_agent/session.py +618 -0
- package/server/services/cli_agent/types.py +227 -0
- package/server/services/cli_agent/workflow_tools.py +233 -0
- package/server/services/credential_registry.py +26 -1
- package/server/services/deployment/manager.py +26 -145
- package/server/services/deployment/poll_registry.py +59 -0
- package/server/services/event_waiter.py +76 -246
- package/server/services/events/__init__.py +54 -0
- package/server/services/events/cli.py +78 -0
- package/server/services/events/daemon.py +163 -0
- package/server/services/events/envelope.py +281 -0
- package/server/services/events/lifecycle.py +99 -0
- package/server/services/events/oauth_lifecycle.py +534 -0
- package/server/services/events/polling.py +60 -0
- package/server/services/events/push.py +36 -0
- package/server/services/events/source.py +63 -0
- package/server/services/events/triggers.py +118 -0
- package/server/services/events/verifiers/__init__.py +25 -0
- package/server/services/events/verifiers/base.py +28 -0
- package/server/services/events/verifiers/github.py +25 -0
- package/server/services/events/verifiers/hmac_basic.py +32 -0
- package/server/services/events/verifiers/standard_webhooks.py +47 -0
- package/server/services/events/verifiers/stripe.py +42 -0
- package/server/services/events/webhook.py +105 -0
- package/server/services/handlers/tools.py +28 -186
- package/server/services/llm/config.py +7 -0
- package/server/services/llm/factory.py +8 -2
- package/server/services/memory/__init__.py +52 -0
- package/server/services/memory/jsonl.py +80 -0
- package/server/services/memory/markdown.py +65 -0
- package/server/services/memory/state.py +112 -0
- package/server/services/memory/vector_store.py +40 -0
- package/server/services/model_registry.py +76 -0
- package/server/services/node_allowlist.py +71 -15
- package/server/services/node_executor.py +2 -2
- package/server/services/node_output_schemas.py +21 -10
- package/server/services/node_spec.py +1 -1
- package/server/services/oauth_utils.py +1 -1
- package/server/services/plugin/__init__.py +2 -0
- package/server/services/plugin/base.py +44 -2
- package/server/services/plugin/credential.py +288 -1
- package/server/services/plugin/deps.py +105 -0
- package/server/services/plugin/edge_walker.py +12 -4
- package/server/services/plugin/oauth.py +381 -0
- package/server/services/plugin/polling.py +247 -0
- package/server/services/plugin/registry.py +145 -0
- package/server/services/plugin/singleton.py +65 -0
- package/server/services/plugin/ws.py +81 -0
- package/server/services/process_service.py +31 -2
- package/server/services/status_broadcaster.py +155 -238
- package/server/services/temporal/workflow.py +7 -7
- package/server/services/workflow.py +21 -3
- package/server/services/ws_handler_registry.py +111 -28
- package/server/skills/GUIDE.md +16 -1
- package/server/skills/assistant/agent-builder-skill/SKILL.md +166 -0
- package/server/skills/payments_agent/stripe-skill/SKILL.md +306 -0
- package/server/tests/credentials/test_auth_service.py +16 -9
- package/server/tests/credentials/test_credential_broadcasts.py +219 -0
- package/server/tests/credentials/test_google_oauth.py +6 -6
- package/server/tests/credentials/test_oauth_utils.py +1 -1
- package/server/tests/credentials/test_twitter_oauth.py +2 -2
- package/server/tests/credentials/test_websocket_handlers.py +44 -20
- package/server/tests/llm/test_factory.py +1 -0
- package/server/tests/llm/test_wiring.py +5 -1
- package/server/tests/nodes/_compat.py +24 -24
- package/server/tests/nodes/test_agent_builder.py +439 -0
- package/server/tests/nodes/test_ai_tools.py +18 -14
- package/server/tests/nodes/test_code_fs_process.py +17 -8
- package/server/tests/nodes/test_email.py +10 -9
- package/server/tests/nodes/test_google_workspace.py +2 -2
- package/server/tests/nodes/test_specialized_agents.py +100 -53
- package/server/tests/nodes/test_stripe_plugin.py +293 -0
- package/server/tests/nodes/test_telegram_social.py +4 -4
- package/server/tests/nodes/test_twitter.py +1 -1
- package/server/tests/nodes/test_web_automation.py +2 -2
- package/server/tests/nodes/test_whatsapp.py +9 -9
- package/server/tests/services/cli_agent/__init__.py +0 -0
- package/server/tests/services/cli_agent/test_mcp_server.py +432 -0
- package/server/tests/services/cli_agent/test_providers.py +358 -0
- package/server/tests/services/cli_agent/test_service.py +298 -0
- package/server/tests/services/memory/__init__.py +0 -0
- package/server/tests/services/memory/test_jsonl.py +188 -0
- package/server/tests/services/test_events.py +333 -0
- package/server/tests/test_node_spec.py +56 -16
- package/server/tests/test_plugin_helpers.py +116 -0
- package/server/tests/test_plugin_self_containment.py +486 -0
- package/server/tests/test_status_broadcasts.py +425 -0
- package/workflows/{AI Assistant_workflow-1777421105154-0m4snkzjf.json → AI Assistant_workflow-1778504793388-ou1m1tz2x.json } +70 -266
- package/workflows/{AI Employee_workflow-1777720598005-u4cm858dv.json → AI Employee_example_workflow-1777720598005-u4cm858dv.json } +112 -112
- package/workflows/Claude Assistant_workflow-1778380124051-mdibn807c.json +709 -0
- package/client/dist/assets/ActionBar-vzPpSR77.js +0 -1
- package/client/dist/assets/ApiKeyInput-Ds7AKFe8.js +0 -1
- package/client/dist/assets/ApiKeyPanel-gfblELep.js +0 -1
- package/client/dist/assets/ApiUsageSection-BMNWTe2r.js +0 -1
- package/client/dist/assets/EmailPanel-B1Om64p5.js +0 -1
- package/client/dist/assets/OAuthPanel-CXyQYGBz.js +0 -1
- package/client/dist/assets/QrPairingPanel-BgNuI1we.js +0 -1
- package/client/dist/assets/RateLimitSection-YYK8sx1T.js +0 -1
- package/client/dist/assets/StatusCard-DuYA5hJR.js +0 -1
- package/client/dist/assets/index-D9tZfgvi.js +0 -363
- package/client/dist/assets/index-al7snTkG.css +0 -1
- package/client/src/components/credentials/providers.tsx +0 -177
- package/server/routers/google.py +0 -277
- package/server/routers/maps.py +0 -142
- package/server/routers/twitter.py +0 -365
- package/server/services/claude_code_service.py +0 -106
- package/server/services/memory.py +0 -159
- package/server/services/node_option_loaders/__init__.py +0 -77
- package/server/services/node_option_loaders/android_loaders.py +0 -55
- package/server/services/node_option_loaders/google_loaders.py +0 -97
- package/server/services/node_option_loaders/whatsapp_loaders.py +0 -69
- package/server/services/twitter_oauth.py +0 -411
- package/server/services/websocket_client.py +0 -29
- /package/server/{services/android → nodes/android/_relay}/__init__.py +0 -0
- /package/server/{services/android → nodes/android/_relay}/broadcaster.py +0 -0
- /package/server/{services/android → nodes/android/_relay}/manager.py +0 -0
- /package/server/{services/android → nodes/android/_relay}/protocol.py +0 -0
- /package/server/{services/browser_service.py → nodes/browser/_service.py} +0 -0
- /package/server/{services/whatsapp_service.py → nodes/whatsapp/_service.py} +0 -0
- /package/server/skills/{task_agent → assistant}/write-todos-skill/SKILL.md +0 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Local-LLM credential validator (Ollama, LM Studio).
|
|
2
|
+
|
|
3
|
+
Lives next to the chat-model plugins so all per-provider behaviour for
|
|
4
|
+
the local servers stays in `nodes/model/`. Registered into
|
|
5
|
+
`routers.websocket._SPECIAL_PROVIDER_VALIDATORS` from the same place
|
|
6
|
+
the cloud-provider mapping is declared — same shim shape as apify and
|
|
7
|
+
google_maps, the function body just lives here instead.
|
|
8
|
+
|
|
9
|
+
The frontend reuses the standard ``validate_api_key`` WebSocket message
|
|
10
|
+
for these providers; the ``api_key`` field carries the user's Base URL,
|
|
11
|
+
not a secret. We:
|
|
12
|
+
|
|
13
|
+
1. Save the URL under ``{provider}_proxy`` — the existing Ollama-style
|
|
14
|
+
auth-delegation key that the runtime path in ``services/ai.py``
|
|
15
|
+
already reads.
|
|
16
|
+
2. Probe the user's server via the *official* SDK (``ollama`` for
|
|
17
|
+
Ollama, ``lmstudio`` for LM Studio) — list installed models and
|
|
18
|
+
their actually-loaded ``context_length``. SDK-driven introspection
|
|
19
|
+
beats hand-rolled httpx against ``/api/show`` / ``/api/v0/models``
|
|
20
|
+
because the SDK ships the typed result struct (``ShowResponse``,
|
|
21
|
+
``LlmInstanceInfo``) and stays compatible with version drift
|
|
22
|
+
upstream.
|
|
23
|
+
3. Store the placeholder api_key + discovered model list + per-model
|
|
24
|
+
``context_length`` under the provider id. ``model_registry`` reads
|
|
25
|
+
the per-model context at runtime so a chat call never assumes a
|
|
26
|
+
bogus 32K when the user has a 4K-loaded model.
|
|
27
|
+
4. Return ``valid=True`` only when at least one model was found, so a
|
|
28
|
+
misconfigured URL surfaces as a clear "no models" message instead
|
|
29
|
+
of a silent success.
|
|
30
|
+
|
|
31
|
+
Connection failures (server down, wrong port, auth refused, timeout)
|
|
32
|
+
are caught off the SDK's own exceptions and mapped to specific
|
|
33
|
+
user-facing toasts — operators see "is the server running?" for
|
|
34
|
+
connect-refused, "wrong path" for 404, etc. The catalogue ``stored``
|
|
35
|
+
flag flips to False on every failure path via the broadcaster, so a
|
|
36
|
+
failed re-probe clears the previously-green palette dot.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import time
|
|
42
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
43
|
+
|
|
44
|
+
import httpx
|
|
45
|
+
import lmstudio
|
|
46
|
+
import ollama
|
|
47
|
+
from core.logging import get_logger
|
|
48
|
+
from services.plugin.deps import get_auth_service
|
|
49
|
+
from services.status_broadcaster import get_status_broadcaster
|
|
50
|
+
|
|
51
|
+
logger = get_logger(__name__)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _classify_http_error(provider: str, base_url: str, exc: BaseException) -> Tuple[str, str]:
|
|
55
|
+
"""Map an httpx / generic exception to (log_summary, user_message).
|
|
56
|
+
|
|
57
|
+
Both SDKs (ollama-python, lmstudio) raise httpx errors underneath.
|
|
58
|
+
Mapping these directly gives clean operator logs ("connect-refused"
|
|
59
|
+
vs "404 vs "timeout") + actionable user toasts without depending
|
|
60
|
+
on the openai SDK exception hierarchy.
|
|
61
|
+
"""
|
|
62
|
+
display = "LM Studio" if provider == "lmstudio" else provider.capitalize()
|
|
63
|
+
|
|
64
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
65
|
+
return ("timeout", f"Request to {base_url} timed out — server may be overloaded or unreachable")
|
|
66
|
+
|
|
67
|
+
if isinstance(exc, httpx.ConnectError):
|
|
68
|
+
return ("connect-refused",
|
|
69
|
+
f"Could not reach {display} at {base_url}. Is the server running?")
|
|
70
|
+
|
|
71
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
72
|
+
status = exc.response.status_code
|
|
73
|
+
if status in (401, 403):
|
|
74
|
+
return (f"HTTP {status}", f"{display} rejected the request — server requires auth.")
|
|
75
|
+
if status == 404:
|
|
76
|
+
url = base_url.rstrip("/")
|
|
77
|
+
if url.endswith("/v1"):
|
|
78
|
+
hint = f"the {display} server is reachable but expected endpoints aren't exposed — check the server version."
|
|
79
|
+
else:
|
|
80
|
+
hint = f"the URL likely needs to end with `/v1` (e.g. {url}/v1)."
|
|
81
|
+
return (f"HTTP {status}", f"{display} returned 404 — {hint}")
|
|
82
|
+
if status == 429:
|
|
83
|
+
return (f"HTTP {status}", f"{display} rate-limited the request — try again shortly.")
|
|
84
|
+
return (f"HTTP {status}", f"{display} returned HTTP {status} — check the server logs.")
|
|
85
|
+
|
|
86
|
+
if isinstance(exc, httpx.RequestError):
|
|
87
|
+
return ("network-error", f"Network error reaching {display} at {base_url}: {exc.__class__.__name__}")
|
|
88
|
+
|
|
89
|
+
# Unknown exception type: surface the class name + message so the
|
|
90
|
+
# operator can tell what they're looking at without a stacktrace.
|
|
91
|
+
return (type(exc).__name__, f"Could not reach {display}: {exc}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _fail(
|
|
95
|
+
provider: str,
|
|
96
|
+
message: str,
|
|
97
|
+
*,
|
|
98
|
+
has_key: bool = False,
|
|
99
|
+
) -> Dict[str, Any]:
|
|
100
|
+
"""Common rejection path: broadcast invalid status + return envelope.
|
|
101
|
+
|
|
102
|
+
`has_key=True` is used after the URL has been persisted so the
|
|
103
|
+
palette doesn't go straight to the "unconfigured" gray dot — the
|
|
104
|
+
user's URL is on file, it just can't reach a working server right
|
|
105
|
+
now. `has_key=False` is used when we abort before persisting (only
|
|
106
|
+
the "Base URL required" early-exit today).
|
|
107
|
+
"""
|
|
108
|
+
await get_status_broadcaster().update_api_key_status(
|
|
109
|
+
provider=provider, valid=False, message=message,
|
|
110
|
+
has_key=has_key, models=[],
|
|
111
|
+
)
|
|
112
|
+
return {
|
|
113
|
+
"provider": provider, "success": True, "valid": False,
|
|
114
|
+
"message": message, "models": [], "timestamp": time.time(),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _strip_v1_path(base_url: str) -> str:
|
|
119
|
+
"""Return ``base_url`` with a trailing ``/v1`` segment stripped.
|
|
120
|
+
|
|
121
|
+
The user's stored URL is the OpenAI-compatible base
|
|
122
|
+
(``http://host:port/v1``). Both Ollama's REST API and LM Studio's
|
|
123
|
+
SDK want the host:port without the OpenAI-compat suffix.
|
|
124
|
+
"""
|
|
125
|
+
u = base_url.rstrip("/")
|
|
126
|
+
if u.endswith("/v1"):
|
|
127
|
+
return u[: -len("/v1")]
|
|
128
|
+
return u
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def _fetch_ollama_models(base_url: str) -> List[Dict[str, Any]]:
|
|
132
|
+
"""List currently-loaded Ollama models with their actual params.
|
|
133
|
+
|
|
134
|
+
Uses ``ollama.AsyncClient.ps()`` — the official "list running models"
|
|
135
|
+
endpoint. Returns a typed ``ProcessResponse`` whose ``models[]``
|
|
136
|
+
entries already carry every field we need as proper Pydantic
|
|
137
|
+
attributes (no dict-key hunting, no Modelfile parameters parsing):
|
|
138
|
+
|
|
139
|
+
- ``model`` — canonical name passed to ``/v1/chat/completions``
|
|
140
|
+
- ``context_length``— live server-side n_ctx (this is the value
|
|
141
|
+
that produces the 400 overflow when a
|
|
142
|
+
prompt exceeds it)
|
|
143
|
+
- ``details`` — typed ``ModelDetails`` (family, parameter_size,
|
|
144
|
+
quantization_level)
|
|
145
|
+
|
|
146
|
+
If the user has models *pulled* but none currently loaded, ``ps()``
|
|
147
|
+
returns an empty list — same semantics as LM Studio's
|
|
148
|
+
``list_loaded()``. The validator surfaces this as the "load a model
|
|
149
|
+
and click Fetch again" message, which is already accurate.
|
|
150
|
+
"""
|
|
151
|
+
host = _strip_v1_path(base_url)
|
|
152
|
+
client = ollama.AsyncClient(host=host, timeout=10.0)
|
|
153
|
+
try:
|
|
154
|
+
running = await client.ps()
|
|
155
|
+
finally:
|
|
156
|
+
try:
|
|
157
|
+
await client._client.aclose()
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
out: List[Dict[str, Any]] = []
|
|
162
|
+
for m in running.models or []:
|
|
163
|
+
mid = m.model or m.name
|
|
164
|
+
if not mid:
|
|
165
|
+
continue
|
|
166
|
+
entry: Dict[str, Any] = {"id": mid}
|
|
167
|
+
if m.context_length:
|
|
168
|
+
entry["context_length"] = int(m.context_length)
|
|
169
|
+
if m.details:
|
|
170
|
+
if m.details.family:
|
|
171
|
+
entry["architecture"] = m.details.family
|
|
172
|
+
if m.details.parameter_size:
|
|
173
|
+
entry["param_size"] = m.details.parameter_size
|
|
174
|
+
if m.details.quantization_level:
|
|
175
|
+
entry["quantization"] = m.details.quantization_level
|
|
176
|
+
if m.details.format:
|
|
177
|
+
entry["format"] = m.details.format
|
|
178
|
+
out.append(entry)
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def _fetch_lmstudio_models(base_url: str) -> List[Dict[str, Any]]:
|
|
183
|
+
"""List currently-loaded LM Studio models with their actual params.
|
|
184
|
+
|
|
185
|
+
Uses ``lmstudio.AsyncClient.llm.list_loaded()`` — each handle's
|
|
186
|
+
``get_info()`` returns a typed ``LlmInstanceInfo``. We read only
|
|
187
|
+
SDK-typed fields (``context_length``, ``max_context_length``,
|
|
188
|
+
``vision``, ``trained_for_tool_use``, ``architecture``,
|
|
189
|
+
``params_string``); no string parsing.
|
|
190
|
+
|
|
191
|
+
LM Studio's SDK takes ``api_host`` as ``host:port`` (no scheme, no
|
|
192
|
+
path), so the user's stored ``http://host:port/v1`` is stripped
|
|
193
|
+
before construction.
|
|
194
|
+
"""
|
|
195
|
+
host = _strip_v1_path(base_url)
|
|
196
|
+
api_host = host.split("://", 1)[-1]
|
|
197
|
+
|
|
198
|
+
client = lmstudio.AsyncClient(api_host=api_host)
|
|
199
|
+
out: List[Dict[str, Any]] = []
|
|
200
|
+
async with client:
|
|
201
|
+
loaded = await client.llm.list_loaded()
|
|
202
|
+
for handle in loaded:
|
|
203
|
+
try:
|
|
204
|
+
info = await handle.get_info()
|
|
205
|
+
except Exception as e:
|
|
206
|
+
logger.info("[lmstudio] get_info skipped for %s: %s",
|
|
207
|
+
getattr(handle, "identifier", "<unknown>"), type(e).__name__)
|
|
208
|
+
continue
|
|
209
|
+
mid = info.identifier or info.model_key
|
|
210
|
+
if not mid:
|
|
211
|
+
continue
|
|
212
|
+
entry: Dict[str, Any] = {"id": mid}
|
|
213
|
+
if info.context_length:
|
|
214
|
+
entry["context_length"] = int(info.context_length)
|
|
215
|
+
if info.max_context_length:
|
|
216
|
+
entry["max_context_length"] = int(info.max_context_length)
|
|
217
|
+
if info.vision is not None:
|
|
218
|
+
entry["vision"] = bool(info.vision)
|
|
219
|
+
if info.trained_for_tool_use is not None:
|
|
220
|
+
entry["supports_tools"] = bool(info.trained_for_tool_use)
|
|
221
|
+
if info.architecture:
|
|
222
|
+
entry["architecture"] = info.architecture
|
|
223
|
+
if info.params_string:
|
|
224
|
+
entry["param_size"] = info.params_string
|
|
225
|
+
if info.format:
|
|
226
|
+
entry["format"] = info.format
|
|
227
|
+
out.append(entry)
|
|
228
|
+
return out
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def _fetch_local_models(provider: str, base_url: str) -> List[Dict[str, Any]]:
|
|
232
|
+
"""Dispatch to the per-provider SDK probe."""
|
|
233
|
+
if provider == "ollama":
|
|
234
|
+
return await _fetch_ollama_models(base_url)
|
|
235
|
+
if provider == "lmstudio":
|
|
236
|
+
return await _fetch_lmstudio_models(base_url)
|
|
237
|
+
raise ValueError(f"Unsupported local provider: {provider}")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def validate_local_llm(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
241
|
+
"""Validator for ollama / lmstudio. Returns the standard response envelope.
|
|
242
|
+
|
|
243
|
+
Called from :meth:`nodes.model._credentials._LocalLLM.validate` (the
|
|
244
|
+
``Credential.validate`` hook ``handle_validate_api_key`` dispatches
|
|
245
|
+
to). All side effects (URL persistence, status broadcasts, model
|
|
246
|
+
registry registration) go through the ``StatusBroadcaster`` /
|
|
247
|
+
``AuthService`` singletons — no per-request WebSocket reference is
|
|
248
|
+
needed.
|
|
249
|
+
"""
|
|
250
|
+
provider = data["provider"].lower()
|
|
251
|
+
base_url = data.get("api_key", "").strip()
|
|
252
|
+
session_id = data.get("session_id", "default")
|
|
253
|
+
|
|
254
|
+
if not base_url:
|
|
255
|
+
return {"success": False, "valid": False, "error": "Base URL required"}
|
|
256
|
+
|
|
257
|
+
auth_service = get_auth_service()
|
|
258
|
+
|
|
259
|
+
# Persist the URL first so the runtime path (services/ai.py) can
|
|
260
|
+
# read it via the existing {provider}_proxy lookup even before the
|
|
261
|
+
# probe succeeds. The URL stays persisted on probe failure so the
|
|
262
|
+
# user can re-click "Fetch" without re-entering it; the failure
|
|
263
|
+
# branch broadcasts has_key=True so the palette dot reflects "URL
|
|
264
|
+
# on file but currently unreachable" rather than "unconfigured".
|
|
265
|
+
await auth_service.store_api_key(
|
|
266
|
+
provider=f"{provider}_proxy",
|
|
267
|
+
api_key=base_url,
|
|
268
|
+
models=[],
|
|
269
|
+
session_id=session_id,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
entries = await _fetch_local_models(provider, base_url)
|
|
274
|
+
except (httpx.HTTPError, lmstudio.LMStudioError) as e:
|
|
275
|
+
log_summary, user_msg = _classify_http_error(provider, base_url, e)
|
|
276
|
+
logger.warning("[%s] model probe failed (%s) at %s", provider, log_summary, base_url)
|
|
277
|
+
return await _fail(provider, user_msg, has_key=True)
|
|
278
|
+
except Exception as e:
|
|
279
|
+
log_summary, user_msg = _classify_http_error(provider, base_url, e)
|
|
280
|
+
logger.warning("[%s] model probe unexpected error (%s) at %s: %s",
|
|
281
|
+
provider, log_summary, base_url, e)
|
|
282
|
+
return await _fail(provider, user_msg, has_key=True)
|
|
283
|
+
|
|
284
|
+
if not entries:
|
|
285
|
+
# Server reachable, responded with empty model list. Different
|
|
286
|
+
# failure mode from the connect-error branches above — keep the
|
|
287
|
+
# original "load a model" hint here since it's now accurate.
|
|
288
|
+
display = "LM Studio" if provider == "lmstudio" else provider.capitalize()
|
|
289
|
+
message = f"Connected to {display} at {base_url}, but no models are loaded. Load a model in {display} and click Fetch again."
|
|
290
|
+
logger.info("[%s] reachable at %s but returned 0 models", provider, base_url)
|
|
291
|
+
return await _fail(provider, message, has_key=True)
|
|
292
|
+
|
|
293
|
+
# Pivot the SDK-probed entries into the storage shape: a parallel
|
|
294
|
+
# ``models`` list (for the legacy readers) plus a ``model_params``
|
|
295
|
+
# dict carrying every typed field the SDK exposed
|
|
296
|
+
# (``context_length``, ``vision``, ``supports_tools``,
|
|
297
|
+
# ``architecture``, ``param_size``, ``quantization``,
|
|
298
|
+
# ``max_context_length``). ``model_registry.register_local_model``
|
|
299
|
+
# consumes these directly to populate ``ModelInfo``.
|
|
300
|
+
models = [e["id"] for e in entries]
|
|
301
|
+
model_params: Dict[str, Dict[str, Any]] = {
|
|
302
|
+
e["id"]: {k: v for k, v in e.items() if k != "id"}
|
|
303
|
+
for e in entries
|
|
304
|
+
if any(k != "id" for k in e)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
# Store placeholder api_key + the real model list + per-model params.
|
|
308
|
+
# The placeholder ("ollama") is the documented value the OpenAI-style
|
|
309
|
+
# auth-delegation path expects when no real key is needed; it never
|
|
310
|
+
# leaves the process because the runtime SDK rewrites it when
|
|
311
|
+
# proxy_url is set.
|
|
312
|
+
await auth_service.store_api_key(
|
|
313
|
+
provider=provider,
|
|
314
|
+
api_key="ollama",
|
|
315
|
+
models=models,
|
|
316
|
+
session_id=session_id,
|
|
317
|
+
model_params=model_params,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Register each model in the in-memory model registry so the sync
|
|
321
|
+
# ``get_context_length`` / ``get_max_output_tokens`` lookups pick up
|
|
322
|
+
# the real loaded n_ctx without re-querying the DB on every chat
|
|
323
|
+
# call. Also keeps the runtime path branchless — local and cloud
|
|
324
|
+
# models share the same ``provider/model_id`` registry key.
|
|
325
|
+
from services.model_registry import get_model_registry
|
|
326
|
+
registry = get_model_registry()
|
|
327
|
+
for mid, params in model_params.items():
|
|
328
|
+
registry.register_local_model(provider, mid, params)
|
|
329
|
+
|
|
330
|
+
await get_status_broadcaster().update_api_key_status(
|
|
331
|
+
provider=provider, valid=True,
|
|
332
|
+
message=f"{len(models)} model(s) discovered at {base_url}",
|
|
333
|
+
has_key=True, models=models,
|
|
334
|
+
)
|
|
335
|
+
ctx_summary = ", ".join(
|
|
336
|
+
f"{mid}={p['context_length']}"
|
|
337
|
+
for mid, p in model_params.items()
|
|
338
|
+
) or "no per-model context info"
|
|
339
|
+
logger.info("[%s] discovered %d model(s) at %s (%s)",
|
|
340
|
+
provider, len(models), base_url, ctx_summary)
|
|
341
|
+
return {
|
|
342
|
+
"provider": provider, "success": True, "valid": True,
|
|
343
|
+
"models": models, "message": f"Connected to {provider} at {base_url}",
|
|
344
|
+
"timestamp": time.time(),
|
|
345
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""LM Studio chat-model plugin.
|
|
2
|
+
|
|
3
|
+
Auto-registers via BaseNode.__init_subclass__. LM Studio exposes a pure
|
|
4
|
+
OpenAI-compatible endpoint at ``http://localhost:1234/v1`` by default,
|
|
5
|
+
so the existing OpenAI-compatible fallback in
|
|
6
|
+
``services/llm/factory.py`` routes it through ``OpenAIProvider`` with
|
|
7
|
+
``base_url`` from ``llm_defaults.json`` — same path as deepseek/kimi/
|
|
8
|
+
mistral. The user's custom server URL is stored as the
|
|
9
|
+
``lmstudio_proxy`` credential.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from ._base import ChatModelBase
|
|
13
|
+
|
|
14
|
+
from ._credentials import LMStudioCredential
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LMStudioChatModelNode(ChatModelBase):
|
|
18
|
+
type = "lmstudioChatModel"
|
|
19
|
+
display_name = "LM Studio"
|
|
20
|
+
subtitle = "Chat Model"
|
|
21
|
+
group = ("model",)
|
|
22
|
+
description = "Run local LLMs via LM Studio's OpenAI-compatible server"
|
|
23
|
+
credentials = (LMStudioCredential,)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Ollama chat-model plugin.
|
|
2
|
+
|
|
3
|
+
Auto-registers via BaseNode.__init_subclass__. Same shape as
|
|
4
|
+
``mistral_chat_model.py`` — a single ``ChatModelBase`` subclass plus a
|
|
5
|
+
credential class. Routing happens through the existing OpenAI-compatible
|
|
6
|
+
fallback in ``services/llm/factory.py``: Ollama serves an OpenAI-shaped
|
|
7
|
+
``/v1`` endpoint, so the factory hands it to ``OpenAIProvider`` with
|
|
8
|
+
``base_url`` from ``llm_defaults.json``. The user's custom server URL
|
|
9
|
+
(if not localhost) is stored as the ``ollama_proxy`` credential and
|
|
10
|
+
flows through the same ``proxy_url`` parameter cloud providers already
|
|
11
|
+
use for Ollama-style auth delegation.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from ._base import ChatModelBase
|
|
15
|
+
|
|
16
|
+
from ._credentials import OllamaCredential
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OllamaChatModelNode(ChatModelBase):
|
|
20
|
+
type = "ollamaChatModel"
|
|
21
|
+
display_name = "Ollama"
|
|
22
|
+
subtitle = "Chat Model"
|
|
23
|
+
group = ("model",)
|
|
24
|
+
description = "Run local LLMs via Ollama (llama, mistral, qwen, deepseek-r1, ...)"
|
|
25
|
+
credentials = (OllamaCredential,)
|
|
@@ -23,7 +23,7 @@ async def track_proxy_usage(
|
|
|
23
23
|
workflow_id: Optional[str] = None,
|
|
24
24
|
session_id: str = "default",
|
|
25
25
|
) -> Dict[str, float]:
|
|
26
|
-
from
|
|
26
|
+
from services.plugin.deps import get_database
|
|
27
27
|
from services.pricing import get_pricing_service
|
|
28
28
|
|
|
29
29
|
pricing = get_pricing_service()
|
|
@@ -34,7 +34,7 @@ async def track_proxy_usage(
|
|
|
34
34
|
gb = bytes_transferred / (1024 ** 3)
|
|
35
35
|
total_cost = round(gb * cost_per_gb, 8)
|
|
36
36
|
|
|
37
|
-
db =
|
|
37
|
+
db = get_database()
|
|
38
38
|
await db.save_api_usage_metric({
|
|
39
39
|
"session_id": session_id,
|
|
40
40
|
"node_id": node_id,
|
|
@@ -15,7 +15,7 @@ import httpx
|
|
|
15
15
|
from pydantic import BaseModel, ConfigDict, Field
|
|
16
16
|
|
|
17
17
|
from core.logging import get_logger
|
|
18
|
-
from services.plugin import ActionNode, NodeContext, Operation, TaskQueue
|
|
18
|
+
from services.plugin import ActionNode, NodeContext, NodeUserError, Operation, TaskQueue
|
|
19
19
|
|
|
20
20
|
logger = get_logger(__name__)
|
|
21
21
|
|
|
@@ -162,7 +162,7 @@ async def _list_routing_rules(proxy_svc) -> Dict[str, Any]:
|
|
|
162
162
|
|
|
163
163
|
|
|
164
164
|
async def _add_provider(p: Dict[str, Any], proxy_svc) -> Dict[str, Any]:
|
|
165
|
-
from
|
|
165
|
+
from services.plugin.deps import get_database
|
|
166
166
|
|
|
167
167
|
name = p.get("name", "")
|
|
168
168
|
if not name:
|
|
@@ -176,7 +176,7 @@ async def _add_provider(p: Dict[str, Any], proxy_svc) -> Dict[str, Any]:
|
|
|
176
176
|
except json.JSONDecodeError:
|
|
177
177
|
return {"success": False, "error": f"Invalid url_template JSON: {url_template_raw}"}
|
|
178
178
|
|
|
179
|
-
db =
|
|
179
|
+
db = get_database()
|
|
180
180
|
await db.save_proxy_provider({
|
|
181
181
|
"name": name,
|
|
182
182
|
"enabled": p.get("enabled", True),
|
|
@@ -192,12 +192,12 @@ async def _add_provider(p: Dict[str, Any], proxy_svc) -> Dict[str, Any]:
|
|
|
192
192
|
|
|
193
193
|
|
|
194
194
|
async def _update_provider(p: Dict[str, Any], proxy_svc) -> Dict[str, Any]:
|
|
195
|
-
from
|
|
195
|
+
from services.plugin.deps import get_database
|
|
196
196
|
|
|
197
197
|
name = p.get("name", "")
|
|
198
198
|
if not name:
|
|
199
199
|
return {"success": False, "error": "Provider name is required"}
|
|
200
|
-
db =
|
|
200
|
+
db = get_database()
|
|
201
201
|
existing = await db.get_proxy_provider(name)
|
|
202
202
|
if not existing:
|
|
203
203
|
return {"success": False, "error": f"Provider '{name}' not found"}
|
|
@@ -225,12 +225,12 @@ async def _update_provider(p: Dict[str, Any], proxy_svc) -> Dict[str, Any]:
|
|
|
225
225
|
|
|
226
226
|
|
|
227
227
|
async def _remove_provider(p: Dict[str, Any], proxy_svc) -> Dict[str, Any]:
|
|
228
|
-
from
|
|
228
|
+
from services.plugin.deps import get_database
|
|
229
229
|
|
|
230
230
|
name = p.get("name", "")
|
|
231
231
|
if not name:
|
|
232
232
|
return {"success": False, "error": "Provider name is required"}
|
|
233
|
-
db =
|
|
233
|
+
db = get_database()
|
|
234
234
|
await db.delete_proxy_provider(name)
|
|
235
235
|
if proxy_svc:
|
|
236
236
|
await proxy_svc.reload_providers()
|
|
@@ -238,7 +238,7 @@ async def _remove_provider(p: Dict[str, Any], proxy_svc) -> Dict[str, Any]:
|
|
|
238
238
|
|
|
239
239
|
|
|
240
240
|
async def _set_credentials(p: Dict[str, Any], proxy_svc) -> Dict[str, Any]:
|
|
241
|
-
from
|
|
241
|
+
from services.plugin.deps import get_auth_service
|
|
242
242
|
|
|
243
243
|
name = p.get("name", "")
|
|
244
244
|
if not name:
|
|
@@ -247,7 +247,7 @@ async def _set_credentials(p: Dict[str, Any], proxy_svc) -> Dict[str, Any]:
|
|
|
247
247
|
if not username or not password:
|
|
248
248
|
return {"success": False, "error": "Username and password are required"}
|
|
249
249
|
|
|
250
|
-
auth_svc =
|
|
250
|
+
auth_svc = get_auth_service()
|
|
251
251
|
await auth_svc.store_api_key(f"proxy_{name}_username", username, [])
|
|
252
252
|
await auth_svc.store_api_key(f"proxy_{name}_password", password, [])
|
|
253
253
|
if proxy_svc:
|
|
@@ -291,7 +291,7 @@ async def _test_provider(p: Dict[str, Any], proxy_svc) -> Dict[str, Any]:
|
|
|
291
291
|
|
|
292
292
|
|
|
293
293
|
async def _add_routing_rule(p: Dict[str, Any], proxy_svc) -> Dict[str, Any]:
|
|
294
|
-
from
|
|
294
|
+
from services.plugin.deps import get_database
|
|
295
295
|
|
|
296
296
|
domain_pattern = p.get("domain_pattern", "")
|
|
297
297
|
if not domain_pattern:
|
|
@@ -305,7 +305,7 @@ async def _add_routing_rule(p: Dict[str, Any], proxy_svc) -> Dict[str, Any]:
|
|
|
305
305
|
except json.JSONDecodeError:
|
|
306
306
|
preferred = []
|
|
307
307
|
|
|
308
|
-
db =
|
|
308
|
+
db = get_database()
|
|
309
309
|
await db.save_proxy_routing_rule({
|
|
310
310
|
"domain_pattern": domain_pattern,
|
|
311
311
|
"preferred_providers": json.dumps(preferred),
|
|
@@ -318,12 +318,12 @@ async def _add_routing_rule(p: Dict[str, Any], proxy_svc) -> Dict[str, Any]:
|
|
|
318
318
|
|
|
319
319
|
|
|
320
320
|
async def _remove_routing_rule(p: Dict[str, Any], proxy_svc) -> Dict[str, Any]:
|
|
321
|
-
from
|
|
321
|
+
from services.plugin.deps import get_database
|
|
322
322
|
|
|
323
323
|
rule_id = p.get("rule_id")
|
|
324
324
|
if not rule_id:
|
|
325
325
|
return {"success": False, "error": "rule_id is required"}
|
|
326
|
-
db =
|
|
326
|
+
db = get_database()
|
|
327
327
|
await db.delete_proxy_routing_rule(int(rule_id))
|
|
328
328
|
if proxy_svc:
|
|
329
329
|
await proxy_svc.reload_providers()
|
|
@@ -384,5 +384,5 @@ class ProxyConfigNode(ActionNode):
|
|
|
384
384
|
async def dispatch(self, ctx: NodeContext, params: ProxyConfigParams) -> Any:
|
|
385
385
|
result = await execute_proxy_config(params.model_dump())
|
|
386
386
|
if not result.get("success"):
|
|
387
|
-
raise
|
|
387
|
+
raise NodeUserError(result.get("error") or "Proxy config failed")
|
|
388
388
|
return result
|
|
@@ -6,7 +6,7 @@ from typing import Any, Dict, Literal, Optional
|
|
|
6
6
|
|
|
7
7
|
from pydantic import BaseModel, ConfigDict, Field
|
|
8
8
|
|
|
9
|
-
from services.plugin import ActionNode, NodeContext, Operation, TaskQueue
|
|
9
|
+
from services.plugin import ActionNode, NodeContext, NodeUserError, Operation, TaskQueue
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class ProxyRequestParams(BaseModel):
|
|
@@ -78,7 +78,7 @@ class ProxyRequestNode(ActionNode):
|
|
|
78
78
|
log = get_logger(__name__)
|
|
79
79
|
svc = get_proxy_service()
|
|
80
80
|
if not svc or not svc.is_enabled():
|
|
81
|
-
raise
|
|
81
|
+
raise NodeUserError(
|
|
82
82
|
"Proxy service not initialized. Use proxy_config tool to add a "
|
|
83
83
|
"provider first.",
|
|
84
84
|
)
|
|
@@ -86,7 +86,7 @@ class ProxyRequestNode(ActionNode):
|
|
|
86
86
|
raw = params.model_dump()
|
|
87
87
|
proxy_url = await svc.get_proxy_url(params.url, raw)
|
|
88
88
|
if not proxy_url:
|
|
89
|
-
raise
|
|
89
|
+
raise NodeUserError("No proxy provider available")
|
|
90
90
|
|
|
91
91
|
max_retries = params.max_retries
|
|
92
92
|
failover = raw.get("proxy_failover", True)
|
|
@@ -132,7 +132,7 @@ class ProxyRequestNode(ActionNode):
|
|
|
132
132
|
response_data = response.text
|
|
133
133
|
|
|
134
134
|
if response.status_code >= 400:
|
|
135
|
-
raise
|
|
135
|
+
raise NodeUserError(f"HTTP {response.status_code}: {response_data!r}")
|
|
136
136
|
return {
|
|
137
137
|
"status": response.status_code,
|
|
138
138
|
"data": response_data,
|
|
@@ -6,7 +6,7 @@ subclasses here in future.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
from services.plugin.credential import ApiKeyCredential
|
|
9
|
+
from services.plugin.credential import ApiKeyCredential, ProbeResult
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class ApifyCredential(ApiKeyCredential):
|
|
@@ -17,3 +17,31 @@ class ApifyCredential(ApiKeyCredential):
|
|
|
17
17
|
key_name = "Authorization"
|
|
18
18
|
key_location = "bearer"
|
|
19
19
|
docs_url = "https://docs.apify.com/api/v2"
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
async def _probe(cls, api_key: str) -> ProbeResult:
|
|
23
|
+
"""Probe Apify ``/users/me`` to verify the token + capture
|
|
24
|
+
username / email / plan for display in the credentials panel.
|
|
25
|
+
|
|
26
|
+
``validate_apify_token`` lives on the plugin module
|
|
27
|
+
(``apify_actor.py``) and uses the official ``apify_client`` SDK.
|
|
28
|
+
It returns a dict; we translate to :class:`ProbeResult` so the
|
|
29
|
+
base ``Credential.validate`` handles storage / broadcast.
|
|
30
|
+
"""
|
|
31
|
+
from .apify_actor import validate_apify_token
|
|
32
|
+
|
|
33
|
+
result = await validate_apify_token(api_key)
|
|
34
|
+
if not result.get("valid"):
|
|
35
|
+
return ProbeResult(
|
|
36
|
+
valid=False,
|
|
37
|
+
message=result.get("error", "Invalid API token"),
|
|
38
|
+
)
|
|
39
|
+
return ProbeResult(
|
|
40
|
+
valid=True,
|
|
41
|
+
message=f"Apify token validated — user: {result.get('username', 'unknown')}",
|
|
42
|
+
extra={
|
|
43
|
+
"username": result.get("username"),
|
|
44
|
+
"email": result.get("email"),
|
|
45
|
+
"plan": result.get("plan"),
|
|
46
|
+
},
|
|
47
|
+
)
|
|
@@ -13,7 +13,7 @@ from typing import Any, Dict, List, Literal, Optional
|
|
|
13
13
|
from pydantic import BaseModel, ConfigDict, Field
|
|
14
14
|
|
|
15
15
|
from core.logging import get_logger
|
|
16
|
-
from services.plugin import ActionNode, NodeContext, Operation, TaskQueue
|
|
16
|
+
from services.plugin import ActionNode, NodeContext, NodeUserError, Operation, TaskQueue
|
|
17
17
|
|
|
18
18
|
from ._credentials import ApifyCredential
|
|
19
19
|
|
|
@@ -23,8 +23,8 @@ logger = get_logger(__name__)
|
|
|
23
23
|
async def _get_apify_client():
|
|
24
24
|
"""Return an authenticated Apify client, or None if no token saved."""
|
|
25
25
|
from apify_client import ApifyClientAsync # lazy — optional dep
|
|
26
|
-
from
|
|
27
|
-
auth_service =
|
|
26
|
+
from services.plugin.deps import get_auth_service
|
|
27
|
+
auth_service = get_auth_service()
|
|
28
28
|
api_token = await auth_service.get_api_key("apify", "default")
|
|
29
29
|
if not api_token:
|
|
30
30
|
return None
|
|
@@ -256,7 +256,7 @@ class ApifyActorNode(ActionNode):
|
|
|
256
256
|
async def run(self, ctx: NodeContext, params: ApifyActorParams) -> ApifyActorOutput:
|
|
257
257
|
client = await _get_apify_client()
|
|
258
258
|
if not client:
|
|
259
|
-
raise
|
|
259
|
+
raise NodeUserError(
|
|
260
260
|
"Apify API token not configured. Please add your token in Credentials.",
|
|
261
261
|
)
|
|
262
262
|
|
|
@@ -264,7 +264,7 @@ class ApifyActorNode(ActionNode):
|
|
|
264
264
|
if actor_id == "custom":
|
|
265
265
|
actor_id = params.custom_actor_id
|
|
266
266
|
if not actor_id:
|
|
267
|
-
raise
|
|
267
|
+
raise NodeUserError("Actor ID is required")
|
|
268
268
|
|
|
269
269
|
actor_input = _build_actor_input(params.model_dump())
|
|
270
270
|
timeout_secs = params.timeout
|
|
@@ -282,18 +282,18 @@ class ApifyActorNode(ActionNode):
|
|
|
282
282
|
)
|
|
283
283
|
|
|
284
284
|
if run_info is None:
|
|
285
|
-
raise
|
|
285
|
+
raise NodeUserError("Actor run failed - no result returned")
|
|
286
286
|
|
|
287
287
|
status = run_info.get("status", "UNKNOWN")
|
|
288
288
|
run_id = run_info.get("id", "")
|
|
289
289
|
dataset_id = run_info.get("defaultDatasetId", "")
|
|
290
290
|
|
|
291
291
|
if status == "FAILED":
|
|
292
|
-
raise
|
|
292
|
+
raise NodeUserError(run_info.get("errorMessage", "Actor run failed"))
|
|
293
293
|
if status == "TIMED-OUT":
|
|
294
|
-
raise
|
|
294
|
+
raise NodeUserError("Actor timed out. Try increasing the timeout.")
|
|
295
295
|
if status == "ABORTED":
|
|
296
|
-
raise
|
|
296
|
+
raise NodeUserError("Actor run was aborted")
|
|
297
297
|
|
|
298
298
|
items: List[Dict[str, Any]] = []
|
|
299
299
|
if dataset_id:
|