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
package/server/services/ai.py
CHANGED
|
@@ -41,6 +41,15 @@ import operator
|
|
|
41
41
|
from langchain_core.messages import BaseMessage # eager: needed for AgentState type resolution
|
|
42
42
|
import json
|
|
43
43
|
|
|
44
|
+
# Eager imports — both are tiny and read on every chat/agent execution
|
|
45
|
+
# path. ``openai`` is the canonical SDK whose typed exception hierarchy
|
|
46
|
+
# (``BadRequestError`` / ``AuthenticationError`` / ``RateLimitError`` / …)
|
|
47
|
+
# is the contract this module dispatches on; ``NodeUserError`` is the
|
|
48
|
+
# framework's "user-correctable, no-traceback" sentinel used to surface
|
|
49
|
+
# those typed errors cleanly through ``BaseNode.execute()``.
|
|
50
|
+
import openai
|
|
51
|
+
from services.plugin import NodeUserError
|
|
52
|
+
|
|
44
53
|
if TYPE_CHECKING:
|
|
45
54
|
from langchain_openai import ChatOpenAI # noqa: F401
|
|
46
55
|
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, ToolMessage # noqa: F401
|
|
@@ -456,9 +465,24 @@ def detect_provider_from_model(model: str) -> str:
|
|
|
456
465
|
|
|
457
466
|
|
|
458
467
|
def is_model_valid_for_provider(model: str, provider: str) -> bool:
|
|
459
|
-
"""Check if model name matches the provider's patterns.
|
|
460
|
-
|
|
461
|
-
|
|
468
|
+
"""Check if model name matches the provider's patterns.
|
|
469
|
+
|
|
470
|
+
Pattern-matching is meaningful for cloud providers — `gpt-*` is OpenAI,
|
|
471
|
+
`claude-*` is Anthropic, etc. — and the check guards against picking
|
|
472
|
+
a model from one provider's dropdown after switching to another.
|
|
473
|
+
|
|
474
|
+
For "open-world" providers (OpenRouter proxy, local Ollama / LM Studio
|
|
475
|
+
servers) the model namespace is whatever the user has installed:
|
|
476
|
+
`llama-3.2`, `qwen2.5-coder`, `phi-3-mini`, custom GGUF files, etc.
|
|
477
|
+
None of those contain the literal substrings `ollama` or `lmstudio`,
|
|
478
|
+
so applying the cloud-style filter produces a false negative on every
|
|
479
|
+
valid local model — the call site then "uses default" which for a
|
|
480
|
+
local provider is the SAME model name, emitting a confusing
|
|
481
|
+
"invalid ... using default: <same name>" log line. Treat all three
|
|
482
|
+
as always-valid; the local-server SDK will reject genuinely missing
|
|
483
|
+
models at request time with a clear 404.
|
|
484
|
+
"""
|
|
485
|
+
if provider in ('openrouter', 'ollama', 'lmstudio'):
|
|
462
486
|
return True
|
|
463
487
|
config = get_provider_configs().get(provider)
|
|
464
488
|
if not config:
|
|
@@ -1397,11 +1421,16 @@ class AIService:
|
|
|
1397
1421
|
"""Fetch available models from provider API.
|
|
1398
1422
|
|
|
1399
1423
|
Native providers use their SDK-based fetch_models(). Groq/Cerebras
|
|
1400
|
-
fall back to the LangChain httpx path.
|
|
1424
|
+
fall back to the LangChain httpx path. Local providers (ollama,
|
|
1425
|
+
lmstudio) honour the user's stored ``{provider}_proxy`` base URL
|
|
1426
|
+
the same way ``execute_chat`` does — without this passthrough the
|
|
1427
|
+
probe always hits the JSON default and never sees the user's
|
|
1428
|
+
actual installed models.
|
|
1401
1429
|
"""
|
|
1402
1430
|
# --- Native SDK path ---
|
|
1403
1431
|
if is_native_provider(provider):
|
|
1404
|
-
|
|
1432
|
+
proxy_url = await self.auth.get_api_key(f"{provider}_proxy")
|
|
1433
|
+
native_provider = create_provider(provider, api_key, proxy_url=proxy_url)
|
|
1405
1434
|
return await native_provider.fetch_models(api_key)
|
|
1406
1435
|
|
|
1407
1436
|
# --- LangChain fallback (groq, cerebras) ---
|
|
@@ -1435,10 +1464,14 @@ class AIService:
|
|
|
1435
1464
|
except Exception as e:
|
|
1436
1465
|
logger.warning(f"[AI] Failed to fetch models from {provider} API: {e}")
|
|
1437
1466
|
|
|
1438
|
-
# Fallback to curated list from llm_defaults.json
|
|
1467
|
+
# Fallback to curated list from llm_defaults.json. When the
|
|
1468
|
+
# provider has no default_model declared (intentional for local
|
|
1469
|
+
# servers like ollama/lmstudio), return an empty list so the
|
|
1470
|
+
# frontend dropdown shows a real "no models" empty state instead
|
|
1471
|
+
# of a placeholder name the user doesn't actually have.
|
|
1439
1472
|
if curated:
|
|
1440
1473
|
return curated
|
|
1441
|
-
return [config.default_model]
|
|
1474
|
+
return [config.default_model] if config.default_model else []
|
|
1442
1475
|
|
|
1443
1476
|
async def execute_chat(self, node_id: str, node_type: str, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
|
1444
1477
|
"""Execute AI chat model."""
|
|
@@ -1581,6 +1614,16 @@ class AIService:
|
|
|
1581
1614
|
"execution_time": time.time() - start_time
|
|
1582
1615
|
}
|
|
1583
1616
|
|
|
1617
|
+
except openai.OpenAIError as e:
|
|
1618
|
+
# Typed openai SDK exception — context overflow (BadRequestError),
|
|
1619
|
+
# bad key (AuthenticationError), missing model (NotFoundError),
|
|
1620
|
+
# server unreachable (APIConnectionError), etc. All
|
|
1621
|
+
# user-correctable. Propagate as NodeUserError so BaseNode.execute()
|
|
1622
|
+
# logs at WARN with no traceback.
|
|
1623
|
+
log_api_call(logger, provider if 'provider' in locals() else 'unknown',
|
|
1624
|
+
model if 'model' in locals() else 'unknown', "chat", False, error=str(e))
|
|
1625
|
+
raise NodeUserError(str(e)) from e
|
|
1626
|
+
|
|
1584
1627
|
except Exception as e:
|
|
1585
1628
|
logger.error("AI execution failed", node_id=node_id, error=str(e))
|
|
1586
1629
|
log_api_call(logger, provider if 'provider' in locals() else 'unknown',
|
|
@@ -1927,6 +1970,18 @@ class AIService:
|
|
|
1927
1970
|
stream_mode="values",
|
|
1928
1971
|
):
|
|
1929
1972
|
final_state = snapshot
|
|
1973
|
+
# Per-step iteration broadcast (CloudEvents envelope
|
|
1974
|
+
# via broadcast_agent_progress -> wire key
|
|
1975
|
+
# `agent_progress`). The FE updates nodeStatusStore
|
|
1976
|
+
# so the AI agent body shows live "iteration / max".
|
|
1977
|
+
if broadcaster:
|
|
1978
|
+
iter_count = snapshot.get("iteration", 0) if isinstance(snapshot, dict) else 0
|
|
1979
|
+
await broadcaster.broadcast_agent_progress(
|
|
1980
|
+
node_id,
|
|
1981
|
+
workflow_id=workflow_id,
|
|
1982
|
+
iteration=iter_count,
|
|
1983
|
+
max_iterations=recursion_limit,
|
|
1984
|
+
)
|
|
1930
1985
|
except GraphRecursionError:
|
|
1931
1986
|
# Append a terminal AIMessage so downstream extraction (and
|
|
1932
1987
|
# the post-loop _track_token_usage / compact_context call)
|
|
@@ -2060,6 +2115,13 @@ class AIService:
|
|
|
2060
2115
|
"execution_time": time.time() - start_time
|
|
2061
2116
|
}
|
|
2062
2117
|
|
|
2118
|
+
except openai.OpenAIError as e:
|
|
2119
|
+
# See execute_chat for the rationale — typed SDK errors are
|
|
2120
|
+
# user-correctable and re-raised as NodeUserError so BaseNode
|
|
2121
|
+
# logs at WARN without a traceback.
|
|
2122
|
+
log_api_call(logger, provider, model, "agent", False, error=str(e))
|
|
2123
|
+
raise NodeUserError(str(e)) from e
|
|
2124
|
+
|
|
2063
2125
|
except Exception as e:
|
|
2064
2126
|
logger.error("[LangGraph] AI agent execution failed", node_id=node_id, error=str(e))
|
|
2065
2127
|
log_api_call(logger, provider, model, "agent", False, error=str(e))
|
|
@@ -2361,6 +2423,16 @@ class AIService:
|
|
|
2361
2423
|
stream_mode="values",
|
|
2362
2424
|
):
|
|
2363
2425
|
final_state = snapshot
|
|
2426
|
+
# Per-step iteration broadcast — see execute_agent
|
|
2427
|
+
# for the rationale.
|
|
2428
|
+
if broadcaster:
|
|
2429
|
+
iter_count = snapshot.get("iteration", 0) if isinstance(snapshot, dict) else 0
|
|
2430
|
+
await broadcaster.broadcast_agent_progress(
|
|
2431
|
+
node_id,
|
|
2432
|
+
workflow_id=workflow_id,
|
|
2433
|
+
iteration=iter_count,
|
|
2434
|
+
max_iterations=recursion_limit,
|
|
2435
|
+
)
|
|
2364
2436
|
except GraphRecursionError:
|
|
2365
2437
|
msgs = list((final_state or {}).get("messages") or [])
|
|
2366
2438
|
msgs.append(AIMessage(content=(
|
|
@@ -2516,6 +2588,11 @@ class AIService:
|
|
|
2516
2588
|
"execution_time": time.time() - start_time
|
|
2517
2589
|
}
|
|
2518
2590
|
|
|
2591
|
+
except openai.OpenAIError as e:
|
|
2592
|
+
# Typed SDK error — see execute_chat for rationale.
|
|
2593
|
+
log_api_call(logger, provider, model, "chat_agent", False, error=str(e))
|
|
2594
|
+
raise NodeUserError(str(e)) from e
|
|
2595
|
+
|
|
2519
2596
|
except Exception as e:
|
|
2520
2597
|
logger.error("[ChatAgent] Execution failed", node_id=node_id, error=str(e))
|
|
2521
2598
|
log_api_call(logger, provider, model, "chat_agent", False, error=str(e))
|
|
@@ -2623,6 +2700,8 @@ class AIService:
|
|
|
2623
2700
|
# Email (Himalaya CLI, dual-purpose tools)
|
|
2624
2701
|
'emailSend': 'email_send',
|
|
2625
2702
|
'emailRead': 'email_read',
|
|
2703
|
+
# Stripe (CLI pass-through, dual-purpose tool)
|
|
2704
|
+
'stripeAction': 'stripe_action',
|
|
2626
2705
|
}
|
|
2627
2706
|
DEFAULT_TOOL_DESCRIPTIONS = {
|
|
2628
2707
|
'calculatorTool': 'Perform mathematical calculations. Operations: add, subtract, multiply, divide, power, sqrt, mod, abs',
|
|
@@ -2703,6 +2782,8 @@ class AIService:
|
|
|
2703
2782
|
# Email (Himalaya CLI, dual-purpose tools)
|
|
2704
2783
|
'emailSend': 'Send email via SMTP. Specify to, subject, body. Optional: cc, bcc, body_type (text/html).',
|
|
2705
2784
|
'emailRead': 'Read and manage emails via IMAP. Operations: list (envelopes), search (query), read (message by ID), folders (list), move, delete, flag.',
|
|
2785
|
+
# Stripe (CLI pass-through, dual-purpose tool)
|
|
2786
|
+
'stripeAction': "Run any Stripe CLI command and return the parsed JSON response. Pass a 'command' field exactly as you would type after 'stripe ' (e.g. 'customers create --email a@b.com', 'charges list --limit 10', 'payment_intents create --amount 2000 --currency usd', 'refunds create --payment-intent pi_xxx', 'trigger charge.succeeded'). Covers every Stripe resource: customers, charges, payment_intents, refunds, invoices, products, prices, subscriptions, payment_methods, setup_intents, transfers, payouts, plus 'trigger <event>' for synthetic test events.",
|
|
2706
2787
|
}
|
|
2707
2788
|
|
|
2708
2789
|
try:
|
|
@@ -2896,7 +2977,7 @@ class AIService:
|
|
|
2896
2977
|
)
|
|
2897
2978
|
return EmptyAndroidSchema
|
|
2898
2979
|
|
|
2899
|
-
from
|
|
2980
|
+
from nodes.android._dispatcher import SERVICE_ACTIONS
|
|
2900
2981
|
|
|
2901
2982
|
service_info = []
|
|
2902
2983
|
for svc in connected_services:
|
package/server/services/auth.py
CHANGED
|
@@ -1,8 +1,32 @@
|
|
|
1
|
-
"""API key management service with encrypted credentials database.
|
|
1
|
+
"""API key management service with encrypted credentials database.
|
|
2
|
+
|
|
3
|
+
Source of truth: ``CredentialsDatabase`` (encrypted SQLite at credentials.db).
|
|
4
|
+
Two derived in-memory caches exist for performance — both keyed by composite
|
|
5
|
+
strings, both invalidated atomically on every DB write/delete:
|
|
6
|
+
|
|
7
|
+
- ``_api_key_cache``: ``Dict[str, ApiKeyCacheEntry]`` keyed by ``{session}_{provider}``.
|
|
8
|
+
One entry carries decrypted key + models + stored_at. Replaces the previous
|
|
9
|
+
pair of ``_memory_cache`` (key) + ``_models_cache`` (models) which shared the
|
|
10
|
+
same key shape but had separate write/evict sites — invitation to drift.
|
|
11
|
+
- ``_oauth_cache``: ``Dict[str, Dict]`` keyed by ``{customer}_{provider}``.
|
|
12
|
+
Different namespace, different shape, different lifecycle — kept separate.
|
|
13
|
+
Per RFC 9700 (OAuth 2.0 BCP 2024) refresh tokens are NOT cached here;
|
|
14
|
+
``get_oauth_refresh_token()`` reads from the DB on every call.
|
|
15
|
+
|
|
16
|
+
Async-safety: every read / write is await-free across the cache check, so
|
|
17
|
+
the GIL guarantees atomicity at the bytecode level — no locks needed. Lazy
|
|
18
|
+
DB fallback inside ``get_api_key`` introduces an await, but the only race
|
|
19
|
+
is "two concurrent reads both miss cache and both fetch from DB" — benign,
|
|
20
|
+
since both writes set the same value.
|
|
21
|
+
|
|
22
|
+
Neither cache has a TTL today; eviction is on explicit ``remove_*`` or
|
|
23
|
+
``clear_cache()`` (logout). External revocation by an upstream provider is
|
|
24
|
+
not detected.
|
|
25
|
+
"""
|
|
2
26
|
|
|
3
27
|
import hashlib
|
|
4
|
-
import
|
|
5
|
-
from
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass, field
|
|
6
30
|
from typing import Dict, Any, Optional, List
|
|
7
31
|
|
|
8
32
|
from core.config import Settings
|
|
@@ -14,6 +38,20 @@ from core.logging import get_logger
|
|
|
14
38
|
logger = get_logger(__name__)
|
|
15
39
|
|
|
16
40
|
|
|
41
|
+
@dataclass
|
|
42
|
+
class ApiKeyCacheEntry:
|
|
43
|
+
"""One in-memory cache entry per ``{session}_{provider}``.
|
|
44
|
+
|
|
45
|
+
Carries the decrypted API key, the models list discovered at validation
|
|
46
|
+
time, and a monotonic timestamp for future TTL support. Single struct
|
|
47
|
+
so the two values can never drift — one write site, one evict site.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
key: str
|
|
51
|
+
models: List[str] = field(default_factory=list)
|
|
52
|
+
stored_at: float = field(default_factory=time.monotonic)
|
|
53
|
+
|
|
54
|
+
|
|
17
55
|
class AuthService:
|
|
18
56
|
"""API key management service using encrypted credentials database.
|
|
19
57
|
|
|
@@ -32,23 +70,51 @@ class AuthService:
|
|
|
32
70
|
self.cache = cache # Kept for backward compatibility, not used for API keys
|
|
33
71
|
self.database = database # Kept for backward compatibility
|
|
34
72
|
self.settings = settings
|
|
35
|
-
# Memory-only cache for decrypted API keys (never persisted
|
|
36
|
-
|
|
37
|
-
#
|
|
38
|
-
self.
|
|
39
|
-
# Memory-only cache for OAuth tokens
|
|
73
|
+
# Memory-only cache for decrypted API keys + models (never persisted
|
|
74
|
+
# to Redis/disk). Single entry per provider; one write site, one
|
|
75
|
+
# evict site — see module docstring.
|
|
76
|
+
self._api_key_cache: Dict[str, ApiKeyCacheEntry] = {}
|
|
77
|
+
# Memory-only cache for OAuth tokens. Different key namespace
|
|
78
|
+
# (``{customer}_{provider}``); kept separate from API keys.
|
|
79
|
+
# Per RFC 9700 the refresh_token is NOT cached here — only
|
|
80
|
+
# access_token + display fields. Refresh-token access goes via
|
|
81
|
+
# ``get_oauth_refresh_token()`` which reads from the DB.
|
|
40
82
|
self._oauth_cache: Dict[str, Dict[str, Any]] = {}
|
|
41
83
|
|
|
42
84
|
def hash_api_key(self, api_key: str) -> str:
|
|
43
85
|
"""Create hash for API key identification."""
|
|
44
86
|
return hashlib.sha256(api_key.encode()).hexdigest()[:16]
|
|
45
87
|
|
|
88
|
+
def _bump_catalogue_version(self) -> None:
|
|
89
|
+
"""Notify the credential registry that a credential has changed.
|
|
90
|
+
|
|
91
|
+
Bumps the registry's mutation counter so the next call to
|
|
92
|
+
``CredentialRegistry.get_version()`` returns a new content hash.
|
|
93
|
+
The frontend's conditional ``since: <prior version>`` fetch then
|
|
94
|
+
receives a fresh catalogue with updated ``stored`` flags
|
|
95
|
+
instead of ``{unchanged: true}``. Without this bump, the version
|
|
96
|
+
is constant for the life of the process and the per-provider
|
|
97
|
+
``stored`` flag stays stale on every connected client until the
|
|
98
|
+
process restarts.
|
|
99
|
+
|
|
100
|
+
Local import to avoid an import cycle at module load time.
|
|
101
|
+
Failures are swallowed (logged at WARNING) so a missing registry
|
|
102
|
+
never blocks a credential mutation from completing.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
from services.credential_registry import get_credential_registry
|
|
106
|
+
|
|
107
|
+
get_credential_registry().invalidate_version()
|
|
108
|
+
except Exception as e: # noqa: BLE001 — best-effort signal
|
|
109
|
+
logger.warning("Failed to bump credential catalogue version: %s", e)
|
|
110
|
+
|
|
46
111
|
async def store_api_key(
|
|
47
112
|
self,
|
|
48
113
|
provider: str,
|
|
49
114
|
api_key: str,
|
|
50
115
|
models: List[str],
|
|
51
|
-
session_id: str = "default"
|
|
116
|
+
session_id: str = "default",
|
|
117
|
+
model_params: Optional[Dict[str, Dict[str, Any]]] = None,
|
|
52
118
|
) -> bool:
|
|
53
119
|
"""Store API key with models in encrypted credentials database.
|
|
54
120
|
|
|
@@ -57,6 +123,11 @@ class AuthService:
|
|
|
57
123
|
api_key: The API key to store (will be encrypted)
|
|
58
124
|
models: List of available models for this key
|
|
59
125
|
session_id: Session identifier for multi-user support
|
|
126
|
+
model_params: Optional per-model parameters (context_length etc.)
|
|
127
|
+
— used by local providers (Ollama, LM Studio) where the
|
|
128
|
+
context window depends on what the user has loaded.
|
|
129
|
+
Forwarded straight to the credentials DB so
|
|
130
|
+
``model_registry`` can read real values at runtime.
|
|
60
131
|
|
|
61
132
|
Returns:
|
|
62
133
|
True if stored successfully, False otherwise
|
|
@@ -66,17 +137,28 @@ class AuthService:
|
|
|
66
137
|
|
|
67
138
|
logger.info(f"Storing API key for provider: {provider}, session: {session_id}")
|
|
68
139
|
|
|
69
|
-
#
|
|
140
|
+
# 1. DB write first (canonical source).
|
|
70
141
|
await self.credentials_db.save_api_key(
|
|
71
142
|
provider=provider,
|
|
72
143
|
api_key=api_key,
|
|
73
144
|
models=models,
|
|
74
|
-
session_id=session_id
|
|
145
|
+
session_id=session_id,
|
|
146
|
+
model_params=model_params,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# 2. Cache update only after DB write succeeds. One entry,
|
|
150
|
+
# not two parallel dicts — see ApiKeyCacheEntry docstring.
|
|
151
|
+
self._api_key_cache[cache_key] = ApiKeyCacheEntry(
|
|
152
|
+
key=api_key,
|
|
153
|
+
models=list(models),
|
|
75
154
|
)
|
|
76
155
|
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
156
|
+
# 3. Bump the catalogue version so the frontend's conditional
|
|
157
|
+
# fetch (``since: <prior version>``) returns a fresh
|
|
158
|
+
# catalogue instead of ``{unchanged: true}`` — without
|
|
159
|
+
# this the per-provider ``stored`` flag stays stale on
|
|
160
|
+
# every connected client until the process restarts.
|
|
161
|
+
self._bump_catalogue_version()
|
|
80
162
|
|
|
81
163
|
logger.info(f"Stored and cached API key for {provider}")
|
|
82
164
|
return True
|
|
@@ -85,6 +167,26 @@ class AuthService:
|
|
|
85
167
|
logger.error("Failed to store API key", provider=provider, error=str(e))
|
|
86
168
|
return False
|
|
87
169
|
|
|
170
|
+
async def get_model_params(
|
|
171
|
+
self, provider: str, session_id: str = "default"
|
|
172
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
173
|
+
"""Return per-model params (context_length etc.) for the provider.
|
|
174
|
+
|
|
175
|
+
Reads straight from the credentials DB — there's no in-memory
|
|
176
|
+
cache for these because they're consulted at most once per
|
|
177
|
+
chat-model execution and the DB read is cheap. Empty dict for
|
|
178
|
+
cloud providers (whose per-model params live in
|
|
179
|
+
``model_registry.json``) and for any local provider that hasn't
|
|
180
|
+
been validated yet.
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
return await self.credentials_db.get_api_key_model_params(
|
|
184
|
+
provider, session_id
|
|
185
|
+
)
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.error("Failed to get model_params", provider=provider, error=str(e))
|
|
188
|
+
return {}
|
|
189
|
+
|
|
88
190
|
async def get_api_key(self, provider: str, session_id: str = "default") -> Optional[str]:
|
|
89
191
|
"""Get decrypted API key.
|
|
90
192
|
|
|
@@ -100,15 +202,23 @@ class AuthService:
|
|
|
100
202
|
try:
|
|
101
203
|
cache_key = f"{session_id}_{provider}"
|
|
102
204
|
|
|
103
|
-
# Check memory cache first (fastest, most secure)
|
|
104
|
-
|
|
105
|
-
|
|
205
|
+
# Check memory cache first (fastest, most secure).
|
|
206
|
+
entry = self._api_key_cache.get(cache_key)
|
|
207
|
+
if entry is not None:
|
|
208
|
+
return entry.key
|
|
106
209
|
|
|
107
|
-
# Fallback to encrypted database
|
|
210
|
+
# Fallback to encrypted database. The lazy fetch also pulls
|
|
211
|
+
# the models list so we populate the cache entry fully — no
|
|
212
|
+
# second roundtrip on the next get_stored_models() call.
|
|
108
213
|
api_key = await self.credentials_db.get_api_key(provider, session_id)
|
|
109
214
|
if api_key:
|
|
110
|
-
|
|
111
|
-
|
|
215
|
+
models = await self.credentials_db.get_api_key_models(
|
|
216
|
+
provider, session_id
|
|
217
|
+
) or []
|
|
218
|
+
self._api_key_cache[cache_key] = ApiKeyCacheEntry(
|
|
219
|
+
key=api_key,
|
|
220
|
+
models=models,
|
|
221
|
+
)
|
|
112
222
|
return api_key
|
|
113
223
|
|
|
114
224
|
return None
|
|
@@ -131,13 +241,21 @@ class AuthService:
|
|
|
131
241
|
cache_key = f"{session_id}_{provider}"
|
|
132
242
|
|
|
133
243
|
# Check memory cache first
|
|
134
|
-
|
|
135
|
-
|
|
244
|
+
entry = self._api_key_cache.get(cache_key)
|
|
245
|
+
if entry is not None:
|
|
246
|
+
return entry.models
|
|
136
247
|
|
|
137
|
-
# Fallback to encrypted database
|
|
248
|
+
# Fallback to encrypted database. Pull the key alongside so
|
|
249
|
+
# the cache entry is populated fully — symmetric with
|
|
250
|
+
# get_api_key()'s lazy-populate path.
|
|
138
251
|
models = await self.credentials_db.get_api_key_models(provider, session_id)
|
|
139
252
|
if models:
|
|
140
|
-
self.
|
|
253
|
+
api_key = await self.credentials_db.get_api_key(provider, session_id)
|
|
254
|
+
if api_key:
|
|
255
|
+
self._api_key_cache[cache_key] = ApiKeyCacheEntry(
|
|
256
|
+
key=api_key,
|
|
257
|
+
models=list(models),
|
|
258
|
+
)
|
|
141
259
|
return models
|
|
142
260
|
|
|
143
261
|
return []
|
|
@@ -159,13 +277,15 @@ class AuthService:
|
|
|
159
277
|
try:
|
|
160
278
|
cache_key = f"{session_id}_{provider}"
|
|
161
279
|
|
|
162
|
-
#
|
|
163
|
-
self._memory_cache.pop(cache_key, None)
|
|
164
|
-
self._models_cache.pop(cache_key, None)
|
|
165
|
-
|
|
166
|
-
# Remove from encrypted database
|
|
280
|
+
# 1. DB delete first (canonical source).
|
|
167
281
|
await self.credentials_db.delete_api_key(provider, session_id)
|
|
168
282
|
|
|
283
|
+
# 2. Cache evict only after DB succeeds. One pop, not two.
|
|
284
|
+
self._api_key_cache.pop(cache_key, None)
|
|
285
|
+
|
|
286
|
+
# 3. Bump the catalogue version — same reason as store.
|
|
287
|
+
self._bump_catalogue_version()
|
|
288
|
+
|
|
169
289
|
logger.info(f"Removed API key for {provider}")
|
|
170
290
|
return True
|
|
171
291
|
|
|
@@ -192,8 +312,7 @@ class AuthService:
|
|
|
192
312
|
Should be called on user logout to ensure decrypted keys
|
|
193
313
|
don't persist in memory longer than necessary.
|
|
194
314
|
"""
|
|
195
|
-
self.
|
|
196
|
-
self._models_cache.clear()
|
|
315
|
+
self._api_key_cache.clear()
|
|
197
316
|
self._oauth_cache.clear()
|
|
198
317
|
logger.debug("Cleared all credential memory caches")
|
|
199
318
|
|
|
@@ -226,6 +345,7 @@ class AuthService:
|
|
|
226
345
|
try:
|
|
227
346
|
cache_key = f"{customer_id}_{provider}"
|
|
228
347
|
|
|
348
|
+
# 1. DB write first (canonical source).
|
|
229
349
|
await self.credentials_db.save_oauth_tokens(
|
|
230
350
|
provider=provider,
|
|
231
351
|
access_token=access_token,
|
|
@@ -236,15 +356,22 @@ class AuthService:
|
|
|
236
356
|
customer_id=customer_id
|
|
237
357
|
)
|
|
238
358
|
|
|
239
|
-
# Cache
|
|
359
|
+
# 2. Cache only the access token + display fields. Refresh
|
|
360
|
+
# tokens are long-lived secrets per RFC 9700 (OAuth 2.0
|
|
361
|
+
# BCP 2024) and are NOT cached in memory — readers go
|
|
362
|
+
# through ``get_oauth_refresh_token()`` which hits the DB.
|
|
240
363
|
self._oauth_cache[cache_key] = {
|
|
241
364
|
"access_token": access_token,
|
|
242
|
-
"refresh_token": refresh_token,
|
|
243
365
|
"email": email,
|
|
244
366
|
"name": name,
|
|
245
|
-
"scopes": scopes
|
|
367
|
+
"scopes": scopes,
|
|
246
368
|
}
|
|
247
369
|
|
|
370
|
+
# 3. Bump the catalogue version so the frontend's conditional
|
|
371
|
+
# fetch returns fresh ``stored`` flags after this OAuth
|
|
372
|
+
# save (login).
|
|
373
|
+
self._bump_catalogue_version()
|
|
374
|
+
|
|
248
375
|
logger.info(f"Stored OAuth tokens for {provider}")
|
|
249
376
|
return True
|
|
250
377
|
|
|
@@ -257,27 +384,42 @@ class AuthService:
|
|
|
257
384
|
provider: str,
|
|
258
385
|
customer_id: str = "owner"
|
|
259
386
|
) -> Optional[Dict[str, Any]]:
|
|
260
|
-
"""Get OAuth tokens from cache or encrypted database.
|
|
387
|
+
"""Get OAuth display tokens from cache or encrypted database.
|
|
388
|
+
|
|
389
|
+
Returns ``{access_token, email, name, scopes}`` only — the
|
|
390
|
+
refresh token is intentionally NOT included. Callers that need
|
|
391
|
+
the refresh token (token-refresh + revoke flows) must call
|
|
392
|
+
:meth:`get_oauth_refresh_token` explicitly. This split implements
|
|
393
|
+
RFC 9700 (OAuth 2.0 BCP 2024) §5.1 — refresh tokens are
|
|
394
|
+
long-lived secrets and must not live in process memory.
|
|
261
395
|
|
|
262
396
|
Args:
|
|
263
397
|
provider: OAuth provider name
|
|
264
398
|
customer_id: Customer identifier
|
|
265
399
|
|
|
266
400
|
Returns:
|
|
267
|
-
Dict with access_token
|
|
401
|
+
Dict with ``access_token``, ``email``, ``name``, ``scopes`` or
|
|
402
|
+
``None`` if no tokens are stored.
|
|
268
403
|
"""
|
|
269
404
|
try:
|
|
270
405
|
cache_key = f"{customer_id}_{provider}"
|
|
271
406
|
|
|
272
|
-
# Check memory cache first
|
|
407
|
+
# Check memory cache first.
|
|
273
408
|
if cache_key in self._oauth_cache:
|
|
274
409
|
return self._oauth_cache[cache_key]
|
|
275
410
|
|
|
276
|
-
# Fallback to encrypted database
|
|
411
|
+
# Fallback to encrypted database. Strip refresh_token before
|
|
412
|
+
# caching so the in-memory copy is short-lived-display-only.
|
|
277
413
|
tokens = await self.credentials_db.get_oauth_tokens(provider, customer_id)
|
|
278
414
|
if tokens:
|
|
279
|
-
|
|
280
|
-
|
|
415
|
+
display = {
|
|
416
|
+
"access_token": tokens.get("access_token"),
|
|
417
|
+
"email": tokens.get("email"),
|
|
418
|
+
"name": tokens.get("name"),
|
|
419
|
+
"scopes": tokens.get("scopes"),
|
|
420
|
+
}
|
|
421
|
+
self._oauth_cache[cache_key] = display
|
|
422
|
+
return display
|
|
281
423
|
|
|
282
424
|
return None
|
|
283
425
|
|
|
@@ -285,6 +427,38 @@ class AuthService:
|
|
|
285
427
|
logger.error("Failed to get OAuth tokens", provider=provider, error=str(e))
|
|
286
428
|
return None
|
|
287
429
|
|
|
430
|
+
async def get_oauth_refresh_token(
|
|
431
|
+
self,
|
|
432
|
+
provider: str,
|
|
433
|
+
customer_id: str = "owner",
|
|
434
|
+
) -> Optional[str]:
|
|
435
|
+
"""Read the OAuth refresh token directly from the encrypted DB.
|
|
436
|
+
|
|
437
|
+
Per RFC 9700 (OAuth 2.0 BCP 2024) §5.1 the refresh token is a
|
|
438
|
+
long-lived bearer secret and must not be cached in process
|
|
439
|
+
memory. Every call decrypts from disk; this is acceptable
|
|
440
|
+
because refresh tokens are accessed rarely (only at access-token
|
|
441
|
+
renewal + on revoke / logout).
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
provider: OAuth provider name
|
|
445
|
+
customer_id: Customer identifier
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
The decrypted refresh token, or ``None`` if no tokens are
|
|
449
|
+
stored for ``(provider, customer_id)``.
|
|
450
|
+
"""
|
|
451
|
+
try:
|
|
452
|
+
tokens = await self.credentials_db.get_oauth_tokens(provider, customer_id)
|
|
453
|
+
if tokens:
|
|
454
|
+
return tokens.get("refresh_token")
|
|
455
|
+
return None
|
|
456
|
+
except Exception as e:
|
|
457
|
+
logger.error(
|
|
458
|
+
"Failed to get OAuth refresh token", provider=provider, error=str(e)
|
|
459
|
+
)
|
|
460
|
+
return None
|
|
461
|
+
|
|
288
462
|
async def refresh_oauth_tokens_with_breaker(
|
|
289
463
|
self,
|
|
290
464
|
provider: str,
|
|
@@ -388,11 +562,14 @@ class AuthService:
|
|
|
388
562
|
try:
|
|
389
563
|
cache_key = f"{customer_id}_{provider}"
|
|
390
564
|
|
|
391
|
-
#
|
|
565
|
+
# 1. DB delete first (canonical source).
|
|
566
|
+
await self.credentials_db.delete_oauth_tokens(provider, customer_id)
|
|
567
|
+
|
|
568
|
+
# 2. Cache evict after DB succeeds.
|
|
392
569
|
self._oauth_cache.pop(cache_key, None)
|
|
393
570
|
|
|
394
|
-
#
|
|
395
|
-
|
|
571
|
+
# 3. Bump the catalogue version — same reason as store.
|
|
572
|
+
self._bump_catalogue_version()
|
|
396
573
|
|
|
397
574
|
logger.info(f"Removed OAuth tokens for {provider}")
|
|
398
575
|
return True
|