provekit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- provekit/__init__.py +0 -0
- provekit/cli.py +256 -0
- provekit/config.py +77 -0
- provekit/database.py +132 -0
- provekit/main.py +115 -0
- provekit/migrations/env.py +40 -0
- provekit/migrations/script.py.mako +22 -0
- provekit/migrations/versions/323eb73d463c_user_email_verified.py +32 -0
- provekit/migrations/versions/49e8ab812556_baseline_schema.py +241 -0
- provekit/migrations/versions/819ed5ff183e_workspace_ingest_key.py +35 -0
- provekit/migrations/versions/a1b2c3d4e5f6_user_token_version.py +27 -0
- provekit/models.py +191 -0
- provekit/observability.py +154 -0
- provekit/routers/__init__.py +0 -0
- provekit/routers/auth.py +150 -0
- provekit/routers/connections.py +270 -0
- provekit/routers/deployments.py +161 -0
- provekit/routers/flows.py +188 -0
- provekit/routers/library.py +265 -0
- provekit/routers/prompts.py +72 -0
- provekit/routers/run.py +203 -0
- provekit/routers/runtime.py +103 -0
- provekit/routers/traces.py +65 -0
- provekit/routers/usage.py +42 -0
- provekit/services/__init__.py +0 -0
- provekit/services/assertions.py +135 -0
- provekit/services/auth.py +131 -0
- provekit/services/deploy.py +63 -0
- provekit/services/dispatch.py +313 -0
- provekit/services/email.py +38 -0
- provekit/services/flow.py +299 -0
- provekit/services/limits.py +109 -0
- provekit/services/masking.py +45 -0
- provekit/services/netguard.py +69 -0
- provekit/services/otel.py +146 -0
- provekit/services/promptfoo.py +92 -0
- provekit/services/providers/__init__.py +0 -0
- provekit/services/providers/a2a_client.py +110 -0
- provekit/services/providers/agent_http.py +55 -0
- provekit/services/providers/llm.py +364 -0
- provekit/services/providers/mcp_client.py +289 -0
- provekit/services/runstore.py +93 -0
- provekit/services/sealing.py +106 -0
- provekit/services/templates.py +69 -0
- provekit/services/testfile.py +122 -0
- provekit/services/tooling.py +194 -0
- provekit/services/workspace.py +91 -0
- provekit/templates/flows/_manifest.json +2594 -0
- provekit/templates/flows/banking-action-items.yaml +43 -0
- provekit/templates/flows/banking-answer.yaml +43 -0
- provekit/templates/flows/banking-classify-intent.yaml +43 -0
- provekit/templates/flows/banking-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/banking-extract-to-json.yaml +43 -0
- provekit/templates/flows/banking-faq-match-answer.yaml +60 -0
- provekit/templates/flows/banking-keyword-extract.yaml +43 -0
- provekit/templates/flows/banking-language-route.yaml +103 -0
- provekit/templates/flows/banking-moderation-gate.yaml +73 -0
- provekit/templates/flows/banking-redact-summarize.yaml +59 -0
- provekit/templates/flows/banking-score-gate.yaml +73 -0
- provekit/templates/flows/banking-sentiment-route.yaml +73 -0
- provekit/templates/flows/banking-severity-tiers.yaml +103 -0
- provekit/templates/flows/banking-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/banking-tag-enrich.yaml +43 -0
- provekit/templates/flows/banking-tone-rewrite.yaml +43 -0
- provekit/templates/flows/banking-translate-reply.yaml +58 -0
- provekit/templates/flows/banking-triage-route.yaml +73 -0
- provekit/templates/flows/community-forum-action-items.yaml +43 -0
- provekit/templates/flows/community-forum-answer.yaml +43 -0
- provekit/templates/flows/community-forum-classify-intent.yaml +43 -0
- provekit/templates/flows/community-forum-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/community-forum-extract-to-json.yaml +43 -0
- provekit/templates/flows/community-forum-faq-match-answer.yaml +60 -0
- provekit/templates/flows/community-forum-keyword-extract.yaml +43 -0
- provekit/templates/flows/community-forum-language-route.yaml +103 -0
- provekit/templates/flows/community-forum-moderation-gate.yaml +73 -0
- provekit/templates/flows/community-forum-redact-summarize.yaml +59 -0
- provekit/templates/flows/community-forum-score-gate.yaml +73 -0
- provekit/templates/flows/community-forum-sentiment-route.yaml +73 -0
- provekit/templates/flows/community-forum-severity-tiers.yaml +103 -0
- provekit/templates/flows/community-forum-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/community-forum-tag-enrich.yaml +43 -0
- provekit/templates/flows/community-forum-tone-rewrite.yaml +43 -0
- provekit/templates/flows/community-forum-translate-reply.yaml +58 -0
- provekit/templates/flows/community-forum-triage-route.yaml +73 -0
- provekit/templates/flows/content-moderation-action-items.yaml +43 -0
- provekit/templates/flows/content-moderation-answer.yaml +43 -0
- provekit/templates/flows/content-moderation-classify-intent.yaml +43 -0
- provekit/templates/flows/content-moderation-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/content-moderation-extract-to-json.yaml +43 -0
- provekit/templates/flows/content-moderation-faq-match-answer.yaml +60 -0
- provekit/templates/flows/content-moderation-keyword-extract.yaml +43 -0
- provekit/templates/flows/content-moderation-language-route.yaml +103 -0
- provekit/templates/flows/content-moderation-moderation-gate.yaml +73 -0
- provekit/templates/flows/content-moderation-redact-summarize.yaml +59 -0
- provekit/templates/flows/content-moderation-score-gate.yaml +73 -0
- provekit/templates/flows/content-moderation-sentiment-route.yaml +73 -0
- provekit/templates/flows/content-moderation-severity-tiers.yaml +103 -0
- provekit/templates/flows/content-moderation-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/content-moderation-tag-enrich.yaml +43 -0
- provekit/templates/flows/content-moderation-tone-rewrite.yaml +43 -0
- provekit/templates/flows/content-moderation-translate-reply.yaml +58 -0
- provekit/templates/flows/content-moderation-triage-route.yaml +73 -0
- provekit/templates/flows/customer-support-action-items.yaml +43 -0
- provekit/templates/flows/customer-support-answer.yaml +43 -0
- provekit/templates/flows/customer-support-classify-intent.yaml +43 -0
- provekit/templates/flows/customer-support-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/customer-support-extract-to-json.yaml +43 -0
- provekit/templates/flows/customer-support-faq-match-answer.yaml +60 -0
- provekit/templates/flows/customer-support-keyword-extract.yaml +43 -0
- provekit/templates/flows/customer-support-language-route.yaml +103 -0
- provekit/templates/flows/customer-support-moderation-gate.yaml +73 -0
- provekit/templates/flows/customer-support-redact-summarize.yaml +59 -0
- provekit/templates/flows/customer-support-score-gate.yaml +73 -0
- provekit/templates/flows/customer-support-sentiment-route.yaml +73 -0
- provekit/templates/flows/customer-support-severity-tiers.yaml +103 -0
- provekit/templates/flows/customer-support-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/customer-support-tag-enrich.yaml +43 -0
- provekit/templates/flows/customer-support-tone-rewrite.yaml +43 -0
- provekit/templates/flows/customer-support-translate-reply.yaml +58 -0
- provekit/templates/flows/customer-support-triage-route.yaml +73 -0
- provekit/templates/flows/developer-tooling-action-items.yaml +43 -0
- provekit/templates/flows/developer-tooling-answer.yaml +43 -0
- provekit/templates/flows/developer-tooling-classify-intent.yaml +43 -0
- provekit/templates/flows/developer-tooling-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/developer-tooling-extract-to-json.yaml +43 -0
- provekit/templates/flows/developer-tooling-faq-match-answer.yaml +60 -0
- provekit/templates/flows/developer-tooling-keyword-extract.yaml +43 -0
- provekit/templates/flows/developer-tooling-language-route.yaml +103 -0
- provekit/templates/flows/developer-tooling-moderation-gate.yaml +73 -0
- provekit/templates/flows/developer-tooling-redact-summarize.yaml +59 -0
- provekit/templates/flows/developer-tooling-score-gate.yaml +73 -0
- provekit/templates/flows/developer-tooling-sentiment-route.yaml +73 -0
- provekit/templates/flows/developer-tooling-severity-tiers.yaml +103 -0
- provekit/templates/flows/developer-tooling-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/developer-tooling-tag-enrich.yaml +43 -0
- provekit/templates/flows/developer-tooling-tone-rewrite.yaml +43 -0
- provekit/templates/flows/developer-tooling-translate-reply.yaml +58 -0
- provekit/templates/flows/developer-tooling-triage-route.yaml +73 -0
- provekit/templates/flows/devrel-action-items.yaml +43 -0
- provekit/templates/flows/devrel-answer.yaml +43 -0
- provekit/templates/flows/devrel-classify-intent.yaml +43 -0
- provekit/templates/flows/devrel-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/devrel-extract-to-json.yaml +43 -0
- provekit/templates/flows/devrel-faq-match-answer.yaml +60 -0
- provekit/templates/flows/devrel-keyword-extract.yaml +43 -0
- provekit/templates/flows/devrel-language-route.yaml +103 -0
- provekit/templates/flows/devrel-moderation-gate.yaml +73 -0
- provekit/templates/flows/devrel-redact-summarize.yaml +59 -0
- provekit/templates/flows/devrel-score-gate.yaml +73 -0
- provekit/templates/flows/devrel-sentiment-route.yaml +73 -0
- provekit/templates/flows/devrel-severity-tiers.yaml +103 -0
- provekit/templates/flows/devrel-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/devrel-tag-enrich.yaml +43 -0
- provekit/templates/flows/devrel-tone-rewrite.yaml +43 -0
- provekit/templates/flows/devrel-translate-reply.yaml +58 -0
- provekit/templates/flows/devrel-triage-route.yaml +73 -0
- provekit/templates/flows/e-commerce-action-items.yaml +43 -0
- provekit/templates/flows/e-commerce-answer.yaml +43 -0
- provekit/templates/flows/e-commerce-classify-intent.yaml +43 -0
- provekit/templates/flows/e-commerce-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/e-commerce-extract-to-json.yaml +43 -0
- provekit/templates/flows/e-commerce-faq-match-answer.yaml +60 -0
- provekit/templates/flows/e-commerce-keyword-extract.yaml +43 -0
- provekit/templates/flows/e-commerce-language-route.yaml +103 -0
- provekit/templates/flows/e-commerce-moderation-gate.yaml +73 -0
- provekit/templates/flows/e-commerce-redact-summarize.yaml +59 -0
- provekit/templates/flows/e-commerce-score-gate.yaml +73 -0
- provekit/templates/flows/e-commerce-sentiment-route.yaml +73 -0
- provekit/templates/flows/e-commerce-severity-tiers.yaml +103 -0
- provekit/templates/flows/e-commerce-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/e-commerce-tag-enrich.yaml +43 -0
- provekit/templates/flows/e-commerce-tone-rewrite.yaml +43 -0
- provekit/templates/flows/e-commerce-translate-reply.yaml +58 -0
- provekit/templates/flows/e-commerce-triage-route.yaml +73 -0
- provekit/templates/flows/education-action-items.yaml +43 -0
- provekit/templates/flows/education-answer.yaml +43 -0
- provekit/templates/flows/education-classify-intent.yaml +43 -0
- provekit/templates/flows/education-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/education-extract-to-json.yaml +43 -0
- provekit/templates/flows/education-faq-match-answer.yaml +60 -0
- provekit/templates/flows/education-keyword-extract.yaml +43 -0
- provekit/templates/flows/education-language-route.yaml +103 -0
- provekit/templates/flows/education-moderation-gate.yaml +73 -0
- provekit/templates/flows/education-redact-summarize.yaml +59 -0
- provekit/templates/flows/education-score-gate.yaml +73 -0
- provekit/templates/flows/education-sentiment-route.yaml +73 -0
- provekit/templates/flows/education-severity-tiers.yaml +103 -0
- provekit/templates/flows/education-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/education-tag-enrich.yaml +43 -0
- provekit/templates/flows/education-tone-rewrite.yaml +43 -0
- provekit/templates/flows/education-translate-reply.yaml +58 -0
- provekit/templates/flows/education-triage-route.yaml +73 -0
- provekit/templates/flows/finance-action-items.yaml +43 -0
- provekit/templates/flows/finance-answer.yaml +43 -0
- provekit/templates/flows/finance-classify-intent.yaml +43 -0
- provekit/templates/flows/finance-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/finance-extract-to-json.yaml +43 -0
- provekit/templates/flows/finance-faq-match-answer.yaml +60 -0
- provekit/templates/flows/finance-keyword-extract.yaml +43 -0
- provekit/templates/flows/finance-language-route.yaml +103 -0
- provekit/templates/flows/finance-moderation-gate.yaml +73 -0
- provekit/templates/flows/finance-redact-summarize.yaml +59 -0
- provekit/templates/flows/finance-score-gate.yaml +73 -0
- provekit/templates/flows/finance-sentiment-route.yaml +73 -0
- provekit/templates/flows/finance-severity-tiers.yaml +103 -0
- provekit/templates/flows/finance-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/finance-tag-enrich.yaml +43 -0
- provekit/templates/flows/finance-tone-rewrite.yaml +43 -0
- provekit/templates/flows/finance-translate-reply.yaml +58 -0
- provekit/templates/flows/finance-triage-route.yaml +73 -0
- provekit/templates/flows/food-delivery-action-items.yaml +43 -0
- provekit/templates/flows/food-delivery-answer.yaml +43 -0
- provekit/templates/flows/food-delivery-classify-intent.yaml +43 -0
- provekit/templates/flows/food-delivery-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/food-delivery-extract-to-json.yaml +43 -0
- provekit/templates/flows/food-delivery-faq-match-answer.yaml +60 -0
- provekit/templates/flows/food-delivery-keyword-extract.yaml +43 -0
- provekit/templates/flows/food-delivery-language-route.yaml +103 -0
- provekit/templates/flows/food-delivery-moderation-gate.yaml +73 -0
- provekit/templates/flows/food-delivery-redact-summarize.yaml +59 -0
- provekit/templates/flows/food-delivery-score-gate.yaml +73 -0
- provekit/templates/flows/food-delivery-sentiment-route.yaml +73 -0
- provekit/templates/flows/food-delivery-severity-tiers.yaml +103 -0
- provekit/templates/flows/food-delivery-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/food-delivery-tag-enrich.yaml +43 -0
- provekit/templates/flows/food-delivery-tone-rewrite.yaml +43 -0
- provekit/templates/flows/food-delivery-translate-reply.yaml +58 -0
- provekit/templates/flows/food-delivery-triage-route.yaml +73 -0
- provekit/templates/flows/gaming-support-action-items.yaml +43 -0
- provekit/templates/flows/gaming-support-answer.yaml +43 -0
- provekit/templates/flows/gaming-support-classify-intent.yaml +43 -0
- provekit/templates/flows/gaming-support-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/gaming-support-extract-to-json.yaml +43 -0
- provekit/templates/flows/gaming-support-faq-match-answer.yaml +60 -0
- provekit/templates/flows/gaming-support-keyword-extract.yaml +43 -0
- provekit/templates/flows/gaming-support-language-route.yaml +103 -0
- provekit/templates/flows/gaming-support-moderation-gate.yaml +73 -0
- provekit/templates/flows/gaming-support-redact-summarize.yaml +59 -0
- provekit/templates/flows/gaming-support-score-gate.yaml +73 -0
- provekit/templates/flows/gaming-support-sentiment-route.yaml +73 -0
- provekit/templates/flows/gaming-support-severity-tiers.yaml +103 -0
- provekit/templates/flows/gaming-support-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/gaming-support-tag-enrich.yaml +43 -0
- provekit/templates/flows/gaming-support-tone-rewrite.yaml +43 -0
- provekit/templates/flows/gaming-support-translate-reply.yaml +58 -0
- provekit/templates/flows/gaming-support-triage-route.yaml +73 -0
- provekit/templates/flows/healthcare-intake-action-items.yaml +43 -0
- provekit/templates/flows/healthcare-intake-answer.yaml +43 -0
- provekit/templates/flows/healthcare-intake-classify-intent.yaml +43 -0
- provekit/templates/flows/healthcare-intake-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/healthcare-intake-extract-to-json.yaml +43 -0
- provekit/templates/flows/healthcare-intake-faq-match-answer.yaml +60 -0
- provekit/templates/flows/healthcare-intake-keyword-extract.yaml +43 -0
- provekit/templates/flows/healthcare-intake-language-route.yaml +103 -0
- provekit/templates/flows/healthcare-intake-moderation-gate.yaml +73 -0
- provekit/templates/flows/healthcare-intake-redact-summarize.yaml +59 -0
- provekit/templates/flows/healthcare-intake-score-gate.yaml +73 -0
- provekit/templates/flows/healthcare-intake-sentiment-route.yaml +73 -0
- provekit/templates/flows/healthcare-intake-severity-tiers.yaml +103 -0
- provekit/templates/flows/healthcare-intake-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/healthcare-intake-tag-enrich.yaml +43 -0
- provekit/templates/flows/healthcare-intake-tone-rewrite.yaml +43 -0
- provekit/templates/flows/healthcare-intake-translate-reply.yaml +58 -0
- provekit/templates/flows/healthcare-intake-triage-route.yaml +73 -0
- provekit/templates/flows/hr-action-items.yaml +43 -0
- provekit/templates/flows/hr-answer.yaml +43 -0
- provekit/templates/flows/hr-classify-intent.yaml +43 -0
- provekit/templates/flows/hr-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/hr-extract-to-json.yaml +43 -0
- provekit/templates/flows/hr-faq-match-answer.yaml +60 -0
- provekit/templates/flows/hr-keyword-extract.yaml +43 -0
- provekit/templates/flows/hr-language-route.yaml +103 -0
- provekit/templates/flows/hr-moderation-gate.yaml +73 -0
- provekit/templates/flows/hr-redact-summarize.yaml +59 -0
- provekit/templates/flows/hr-score-gate.yaml +73 -0
- provekit/templates/flows/hr-sentiment-route.yaml +73 -0
- provekit/templates/flows/hr-severity-tiers.yaml +103 -0
- provekit/templates/flows/hr-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/hr-tag-enrich.yaml +43 -0
- provekit/templates/flows/hr-tone-rewrite.yaml +43 -0
- provekit/templates/flows/hr-translate-reply.yaml +58 -0
- provekit/templates/flows/hr-triage-route.yaml +73 -0
- provekit/templates/flows/insurance-action-items.yaml +43 -0
- provekit/templates/flows/insurance-answer.yaml +43 -0
- provekit/templates/flows/insurance-classify-intent.yaml +43 -0
- provekit/templates/flows/insurance-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/insurance-extract-to-json.yaml +43 -0
- provekit/templates/flows/insurance-faq-match-answer.yaml +60 -0
- provekit/templates/flows/insurance-keyword-extract.yaml +43 -0
- provekit/templates/flows/insurance-language-route.yaml +103 -0
- provekit/templates/flows/insurance-moderation-gate.yaml +73 -0
- provekit/templates/flows/insurance-redact-summarize.yaml +59 -0
- provekit/templates/flows/insurance-score-gate.yaml +73 -0
- provekit/templates/flows/insurance-sentiment-route.yaml +73 -0
- provekit/templates/flows/insurance-severity-tiers.yaml +103 -0
- provekit/templates/flows/insurance-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/insurance-tag-enrich.yaml +43 -0
- provekit/templates/flows/insurance-tone-rewrite.yaml +43 -0
- provekit/templates/flows/insurance-translate-reply.yaml +58 -0
- provekit/templates/flows/insurance-triage-route.yaml +73 -0
- provekit/templates/flows/it-helpdesk-action-items.yaml +43 -0
- provekit/templates/flows/it-helpdesk-answer.yaml +43 -0
- provekit/templates/flows/it-helpdesk-classify-intent.yaml +43 -0
- provekit/templates/flows/it-helpdesk-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/it-helpdesk-extract-to-json.yaml +43 -0
- provekit/templates/flows/it-helpdesk-faq-match-answer.yaml +60 -0
- provekit/templates/flows/it-helpdesk-keyword-extract.yaml +43 -0
- provekit/templates/flows/it-helpdesk-language-route.yaml +103 -0
- provekit/templates/flows/it-helpdesk-moderation-gate.yaml +73 -0
- provekit/templates/flows/it-helpdesk-redact-summarize.yaml +59 -0
- provekit/templates/flows/it-helpdesk-score-gate.yaml +73 -0
- provekit/templates/flows/it-helpdesk-sentiment-route.yaml +73 -0
- provekit/templates/flows/it-helpdesk-severity-tiers.yaml +103 -0
- provekit/templates/flows/it-helpdesk-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/it-helpdesk-tag-enrich.yaml +43 -0
- provekit/templates/flows/it-helpdesk-tone-rewrite.yaml +43 -0
- provekit/templates/flows/it-helpdesk-translate-reply.yaml +58 -0
- provekit/templates/flows/it-helpdesk-triage-route.yaml +73 -0
- provekit/templates/flows/legal-intake-action-items.yaml +43 -0
- provekit/templates/flows/legal-intake-answer.yaml +43 -0
- provekit/templates/flows/legal-intake-classify-intent.yaml +43 -0
- provekit/templates/flows/legal-intake-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/legal-intake-extract-to-json.yaml +43 -0
- provekit/templates/flows/legal-intake-faq-match-answer.yaml +60 -0
- provekit/templates/flows/legal-intake-keyword-extract.yaml +43 -0
- provekit/templates/flows/legal-intake-language-route.yaml +103 -0
- provekit/templates/flows/legal-intake-moderation-gate.yaml +73 -0
- provekit/templates/flows/legal-intake-redact-summarize.yaml +59 -0
- provekit/templates/flows/legal-intake-score-gate.yaml +73 -0
- provekit/templates/flows/legal-intake-sentiment-route.yaml +73 -0
- provekit/templates/flows/legal-intake-severity-tiers.yaml +103 -0
- provekit/templates/flows/legal-intake-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/legal-intake-tag-enrich.yaml +43 -0
- provekit/templates/flows/legal-intake-tone-rewrite.yaml +43 -0
- provekit/templates/flows/legal-intake-translate-reply.yaml +58 -0
- provekit/templates/flows/legal-intake-triage-route.yaml +73 -0
- provekit/templates/flows/logistics-action-items.yaml +43 -0
- provekit/templates/flows/logistics-answer.yaml +43 -0
- provekit/templates/flows/logistics-classify-intent.yaml +43 -0
- provekit/templates/flows/logistics-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/logistics-extract-to-json.yaml +43 -0
- provekit/templates/flows/logistics-faq-match-answer.yaml +60 -0
- provekit/templates/flows/logistics-keyword-extract.yaml +43 -0
- provekit/templates/flows/logistics-language-route.yaml +103 -0
- provekit/templates/flows/logistics-moderation-gate.yaml +73 -0
- provekit/templates/flows/logistics-redact-summarize.yaml +59 -0
- provekit/templates/flows/logistics-score-gate.yaml +73 -0
- provekit/templates/flows/logistics-sentiment-route.yaml +73 -0
- provekit/templates/flows/logistics-severity-tiers.yaml +103 -0
- provekit/templates/flows/logistics-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/logistics-tag-enrich.yaml +43 -0
- provekit/templates/flows/logistics-tone-rewrite.yaml +43 -0
- provekit/templates/flows/logistics-translate-reply.yaml +58 -0
- provekit/templates/flows/logistics-triage-route.yaml +73 -0
- provekit/templates/flows/marketing-action-items.yaml +43 -0
- provekit/templates/flows/marketing-answer.yaml +43 -0
- provekit/templates/flows/marketing-classify-intent.yaml +43 -0
- provekit/templates/flows/marketing-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/marketing-extract-to-json.yaml +43 -0
- provekit/templates/flows/marketing-faq-match-answer.yaml +60 -0
- provekit/templates/flows/marketing-keyword-extract.yaml +43 -0
- provekit/templates/flows/marketing-language-route.yaml +103 -0
- provekit/templates/flows/marketing-moderation-gate.yaml +73 -0
- provekit/templates/flows/marketing-redact-summarize.yaml +59 -0
- provekit/templates/flows/marketing-score-gate.yaml +73 -0
- provekit/templates/flows/marketing-sentiment-route.yaml +73 -0
- provekit/templates/flows/marketing-severity-tiers.yaml +103 -0
- provekit/templates/flows/marketing-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/marketing-tag-enrich.yaml +43 -0
- provekit/templates/flows/marketing-tone-rewrite.yaml +43 -0
- provekit/templates/flows/marketing-translate-reply.yaml +58 -0
- provekit/templates/flows/marketing-triage-route.yaml +73 -0
- provekit/templates/flows/nonprofit-action-items.yaml +43 -0
- provekit/templates/flows/nonprofit-answer.yaml +43 -0
- provekit/templates/flows/nonprofit-classify-intent.yaml +43 -0
- provekit/templates/flows/nonprofit-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/nonprofit-extract-to-json.yaml +43 -0
- provekit/templates/flows/nonprofit-faq-match-answer.yaml +60 -0
- provekit/templates/flows/nonprofit-keyword-extract.yaml +43 -0
- provekit/templates/flows/nonprofit-language-route.yaml +103 -0
- provekit/templates/flows/nonprofit-moderation-gate.yaml +73 -0
- provekit/templates/flows/nonprofit-redact-summarize.yaml +59 -0
- provekit/templates/flows/nonprofit-score-gate.yaml +73 -0
- provekit/templates/flows/nonprofit-sentiment-route.yaml +73 -0
- provekit/templates/flows/nonprofit-severity-tiers.yaml +103 -0
- provekit/templates/flows/nonprofit-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/nonprofit-tag-enrich.yaml +43 -0
- provekit/templates/flows/nonprofit-tone-rewrite.yaml +43 -0
- provekit/templates/flows/nonprofit-translate-reply.yaml +58 -0
- provekit/templates/flows/nonprofit-triage-route.yaml +73 -0
- provekit/templates/flows/real-estate-action-items.yaml +43 -0
- provekit/templates/flows/real-estate-answer.yaml +43 -0
- provekit/templates/flows/real-estate-classify-intent.yaml +43 -0
- provekit/templates/flows/real-estate-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/real-estate-extract-to-json.yaml +43 -0
- provekit/templates/flows/real-estate-faq-match-answer.yaml +60 -0
- provekit/templates/flows/real-estate-keyword-extract.yaml +43 -0
- provekit/templates/flows/real-estate-language-route.yaml +103 -0
- provekit/templates/flows/real-estate-moderation-gate.yaml +73 -0
- provekit/templates/flows/real-estate-redact-summarize.yaml +59 -0
- provekit/templates/flows/real-estate-score-gate.yaml +73 -0
- provekit/templates/flows/real-estate-sentiment-route.yaml +73 -0
- provekit/templates/flows/real-estate-severity-tiers.yaml +103 -0
- provekit/templates/flows/real-estate-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/real-estate-tag-enrich.yaml +43 -0
- provekit/templates/flows/real-estate-tone-rewrite.yaml +43 -0
- provekit/templates/flows/real-estate-translate-reply.yaml +58 -0
- provekit/templates/flows/real-estate-triage-route.yaml +73 -0
- provekit/templates/flows/recruiting-action-items.yaml +43 -0
- provekit/templates/flows/recruiting-answer.yaml +43 -0
- provekit/templates/flows/recruiting-classify-intent.yaml +43 -0
- provekit/templates/flows/recruiting-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/recruiting-extract-to-json.yaml +43 -0
- provekit/templates/flows/recruiting-faq-match-answer.yaml +60 -0
- provekit/templates/flows/recruiting-keyword-extract.yaml +43 -0
- provekit/templates/flows/recruiting-language-route.yaml +103 -0
- provekit/templates/flows/recruiting-moderation-gate.yaml +73 -0
- provekit/templates/flows/recruiting-redact-summarize.yaml +59 -0
- provekit/templates/flows/recruiting-score-gate.yaml +73 -0
- provekit/templates/flows/recruiting-sentiment-route.yaml +73 -0
- provekit/templates/flows/recruiting-severity-tiers.yaml +103 -0
- provekit/templates/flows/recruiting-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/recruiting-tag-enrich.yaml +43 -0
- provekit/templates/flows/recruiting-tone-rewrite.yaml +43 -0
- provekit/templates/flows/recruiting-translate-reply.yaml +58 -0
- provekit/templates/flows/recruiting-triage-route.yaml +73 -0
- provekit/templates/flows/research-action-items.yaml +43 -0
- provekit/templates/flows/research-answer.yaml +43 -0
- provekit/templates/flows/research-classify-intent.yaml +43 -0
- provekit/templates/flows/research-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/research-extract-to-json.yaml +43 -0
- provekit/templates/flows/research-faq-match-answer.yaml +60 -0
- provekit/templates/flows/research-keyword-extract.yaml +43 -0
- provekit/templates/flows/research-language-route.yaml +103 -0
- provekit/templates/flows/research-moderation-gate.yaml +73 -0
- provekit/templates/flows/research-redact-summarize.yaml +59 -0
- provekit/templates/flows/research-score-gate.yaml +73 -0
- provekit/templates/flows/research-sentiment-route.yaml +73 -0
- provekit/templates/flows/research-severity-tiers.yaml +103 -0
- provekit/templates/flows/research-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/research-tag-enrich.yaml +43 -0
- provekit/templates/flows/research-tone-rewrite.yaml +43 -0
- provekit/templates/flows/research-translate-reply.yaml +58 -0
- provekit/templates/flows/research-triage-route.yaml +73 -0
- provekit/templates/flows/sales-lead-action-items.yaml +43 -0
- provekit/templates/flows/sales-lead-answer.yaml +43 -0
- provekit/templates/flows/sales-lead-classify-intent.yaml +43 -0
- provekit/templates/flows/sales-lead-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/sales-lead-extract-to-json.yaml +43 -0
- provekit/templates/flows/sales-lead-faq-match-answer.yaml +60 -0
- provekit/templates/flows/sales-lead-keyword-extract.yaml +43 -0
- provekit/templates/flows/sales-lead-language-route.yaml +103 -0
- provekit/templates/flows/sales-lead-moderation-gate.yaml +73 -0
- provekit/templates/flows/sales-lead-redact-summarize.yaml +59 -0
- provekit/templates/flows/sales-lead-score-gate.yaml +73 -0
- provekit/templates/flows/sales-lead-sentiment-route.yaml +73 -0
- provekit/templates/flows/sales-lead-severity-tiers.yaml +103 -0
- provekit/templates/flows/sales-lead-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/sales-lead-tag-enrich.yaml +43 -0
- provekit/templates/flows/sales-lead-tone-rewrite.yaml +43 -0
- provekit/templates/flows/sales-lead-translate-reply.yaml +58 -0
- provekit/templates/flows/sales-lead-triage-route.yaml +73 -0
- provekit/templates/flows/travel-action-items.yaml +43 -0
- provekit/templates/flows/travel-answer.yaml +43 -0
- provekit/templates/flows/travel-classify-intent.yaml +43 -0
- provekit/templates/flows/travel-draft-critique-revise.yaml +75 -0
- provekit/templates/flows/travel-extract-to-json.yaml +43 -0
- provekit/templates/flows/travel-faq-match-answer.yaml +60 -0
- provekit/templates/flows/travel-keyword-extract.yaml +43 -0
- provekit/templates/flows/travel-language-route.yaml +103 -0
- provekit/templates/flows/travel-moderation-gate.yaml +73 -0
- provekit/templates/flows/travel-redact-summarize.yaml +59 -0
- provekit/templates/flows/travel-score-gate.yaml +73 -0
- provekit/templates/flows/travel-sentiment-route.yaml +73 -0
- provekit/templates/flows/travel-severity-tiers.yaml +103 -0
- provekit/templates/flows/travel-summarize-then-decide.yaml +88 -0
- provekit/templates/flows/travel-tag-enrich.yaml +43 -0
- provekit/templates/flows/travel-tone-rewrite.yaml +43 -0
- provekit/templates/flows/travel-translate-reply.yaml +58 -0
- provekit/templates/flows/travel-triage-route.yaml +73 -0
- provekit-0.1.0.dist-info/METADATA +86 -0
- provekit-0.1.0.dist-info/RECORD +485 -0
- provekit-0.1.0.dist-info/WHEEL +5 -0
- provekit-0.1.0.dist-info/entry_points.txt +2 -0
- provekit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Generic visual-flow engine. Walks a graph of nodes (input · prompt · tool · agent ·
|
|
2
|
+
condition · output), threading a shared context, and streams rich per-node events so the
|
|
3
|
+
canvas can animate + inspect execution. Nodes reuse the providers via dispatch.run_collect.
|
|
4
|
+
Supports breakpoints + single-step (like a debugger).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from . import dispatch
|
|
14
|
+
from .runstore import drop_ctx as _drop_run
|
|
15
|
+
from .runstore import pop_ctx
|
|
16
|
+
from .runstore import store_ctx as _store_run
|
|
17
|
+
|
|
18
|
+
MAX_STEPS = 100
|
|
19
|
+
_REF = re.compile(r"\{\{\s*([\w.\-]+)\s*\}\}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _skey(run_id: str, workspace_id) -> str:
|
|
23
|
+
"""Namespace a stored run context by workspace so /continue can only resume runs from
|
|
24
|
+
the caller's own workspace — a foreign run_id misses the store instead of popping (and
|
|
25
|
+
deleting) another tenant's paused context."""
|
|
26
|
+
return f"{workspace_id if workspace_id is not None else 0}:{run_id}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def pop_run(run_id: str, workspace_id=None) -> dict | None:
|
|
30
|
+
"""Atomically remove and return a paused run context for a workspace (None if absent)."""
|
|
31
|
+
return pop_ctx(_skey(run_id, workspace_id))
|
|
32
|
+
|
|
33
|
+
# Catalog served to the frontend palette + inspector.
|
|
34
|
+
NODE_TYPES = {
|
|
35
|
+
"input": {"label": "Input", "category": "trigger", "color": "muted"},
|
|
36
|
+
"prompt": {"label": "Prompt", "category": "ai", "color": "prompt"},
|
|
37
|
+
"tool": {"label": "Tool", "category": "tool", "color": "tool"},
|
|
38
|
+
"agent": {"label": "Agent", "category": "agent", "color": "agent"},
|
|
39
|
+
"condition": {"label": "Condition", "category": "logic", "color": "purple", "branches": ["true", "false"]},
|
|
40
|
+
"output": {"label": "Output", "category": "output", "color": "ok"},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve(ref: str, ctx: dict):
|
|
45
|
+
parts = [p for p in ref.split(".") if p]
|
|
46
|
+
if not parts:
|
|
47
|
+
return None
|
|
48
|
+
root = parts[0]
|
|
49
|
+
cur = ctx.get("input") if root == "input" else (ctx.get("nodes") or {}).get(root)
|
|
50
|
+
for p in parts[1:]:
|
|
51
|
+
if isinstance(cur, dict):
|
|
52
|
+
cur = cur.get(p)
|
|
53
|
+
elif isinstance(cur, list):
|
|
54
|
+
try:
|
|
55
|
+
cur = cur[int(p)]
|
|
56
|
+
except (ValueError, IndexError):
|
|
57
|
+
return None
|
|
58
|
+
else:
|
|
59
|
+
return None
|
|
60
|
+
return cur
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _interp(text, ctx):
|
|
64
|
+
if not isinstance(text, str):
|
|
65
|
+
return text
|
|
66
|
+
|
|
67
|
+
def sub(m):
|
|
68
|
+
v = _resolve(m.group(1), ctx)
|
|
69
|
+
if v is None:
|
|
70
|
+
return m.group(0)
|
|
71
|
+
return v if isinstance(v, str) else json.dumps(v)
|
|
72
|
+
return _REF.sub(sub, text)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _interp_obj(obj, ctx):
|
|
76
|
+
if isinstance(obj, str):
|
|
77
|
+
return _interp(obj, ctx)
|
|
78
|
+
if isinstance(obj, list):
|
|
79
|
+
return [_interp_obj(x, ctx) for x in obj]
|
|
80
|
+
if isinstance(obj, dict):
|
|
81
|
+
return {k: _interp_obj(v, ctx) for k, v in obj.items()}
|
|
82
|
+
return obj
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _mask_config(cfg):
|
|
86
|
+
"""Mask secrets in a node's config before it's streamed to the canvas — an agent node's
|
|
87
|
+
inline `headers` or `body` can carry a credential that must not leak into run events."""
|
|
88
|
+
from .masking import mask_body, mask_headers
|
|
89
|
+
if not isinstance(cfg, dict):
|
|
90
|
+
return cfg
|
|
91
|
+
hdrs = cfg.get("headers")
|
|
92
|
+
out = mask_body({k: v for k, v in cfg.items() if k != "headers"})
|
|
93
|
+
if isinstance(hdrs, dict):
|
|
94
|
+
out["headers"] = mask_headers(hdrs)
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _trim(v, _d=0):
|
|
99
|
+
if _d > 6:
|
|
100
|
+
return "…"
|
|
101
|
+
if isinstance(v, str):
|
|
102
|
+
return v if len(v) <= 2000 else v[:2000] + "…"
|
|
103
|
+
if isinstance(v, dict):
|
|
104
|
+
return {k: _trim(x, _d + 1) for k, x in list(v.items())[:50]}
|
|
105
|
+
if isinstance(v, list):
|
|
106
|
+
return [_trim(x, _d + 1) for x in v[:40]]
|
|
107
|
+
return v
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _adjacency(edges):
|
|
111
|
+
adj = {}
|
|
112
|
+
for e in edges:
|
|
113
|
+
adj.setdefault(e["source"], []).append(e)
|
|
114
|
+
return adj
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _next(adj, cur, branch):
|
|
118
|
+
outs = adj.get(cur, [])
|
|
119
|
+
if branch is not None:
|
|
120
|
+
for e in outs:
|
|
121
|
+
if (e.get("condition") or {}).get("branch") == branch:
|
|
122
|
+
return e["target"]
|
|
123
|
+
return None
|
|
124
|
+
uncond = [e for e in outs if not (e.get("condition") or {}).get("branch")]
|
|
125
|
+
return (uncond[0]["target"] if uncond else (outs[0]["target"] if outs else None))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _find_trigger(graph):
|
|
129
|
+
indeg = {n["id"]: 0 for n in graph["nodes"]}
|
|
130
|
+
for e in graph["edges"]:
|
|
131
|
+
if e["target"] in indeg:
|
|
132
|
+
indeg[e["target"]] += 1
|
|
133
|
+
for n in graph["nodes"]:
|
|
134
|
+
if n["type"] == "input" and indeg.get(n["id"], 0) == 0:
|
|
135
|
+
return n["id"]
|
|
136
|
+
for n in graph["nodes"]:
|
|
137
|
+
if indeg.get(n["id"], 0) == 0:
|
|
138
|
+
return n["id"]
|
|
139
|
+
return graph["nodes"][0]["id"] if graph["nodes"] else None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def _exec_node(db, node, ctx, variables=None, workspace_id=None):
|
|
143
|
+
"""Execute one node; return (output, branch). `variables` (the active environment)
|
|
144
|
+
fill any {{name}} refs the flow context didn't resolve, matching console behavior."""
|
|
145
|
+
t = node["type"]
|
|
146
|
+
cfg = node.get("config") or {}
|
|
147
|
+
if t == "input":
|
|
148
|
+
return ctx.get("input") or {}, None
|
|
149
|
+
if t == "prompt":
|
|
150
|
+
system = cfg.get("system")
|
|
151
|
+
if cfg.get("prompt_key"): # pull the shared prompt from the registry (single source of truth)
|
|
152
|
+
reg = _registry_prompt(db, cfg["prompt_key"], workspace_id)
|
|
153
|
+
if reg is not None:
|
|
154
|
+
system = reg
|
|
155
|
+
req = {"type": "prompt", "connection_id": cfg.get("connection_id"), "model": cfg.get("model"),
|
|
156
|
+
"system": _interp(system, ctx), "user": _interp(cfg.get("user", ""), ctx),
|
|
157
|
+
"temperature": cfg.get("temperature", 0.7), "max_tokens": cfg.get("max_tokens", 1024)}
|
|
158
|
+
if cfg.get("tools"): # MCP servers this node's model may call
|
|
159
|
+
req["tools"] = cfg["tools"]
|
|
160
|
+
req["max_tool_rounds"] = cfg.get("max_tool_rounds", 5)
|
|
161
|
+
r = await dispatch.run_collect(db, req, variables, workspace_id)
|
|
162
|
+
if r["status"] == "failed":
|
|
163
|
+
raise RuntimeError(r["error"] or "prompt failed")
|
|
164
|
+
# Surface tool calls downstream only when there were some — a tool-free prompt node
|
|
165
|
+
# keeps its existing {"text": ...} output shape.
|
|
166
|
+
out = {"text": r["text"]}
|
|
167
|
+
if r.get("events"):
|
|
168
|
+
out["events"] = r["events"]
|
|
169
|
+
return out, None
|
|
170
|
+
if t == "tool":
|
|
171
|
+
req = {"type": "tool", "connection_id": cfg.get("connection_id"), "tool": cfg.get("tool"),
|
|
172
|
+
"args": _interp_obj(cfg.get("args") or {}, ctx)}
|
|
173
|
+
r = await dispatch.run_collect(db, req, variables, workspace_id)
|
|
174
|
+
if r["status"] == "failed":
|
|
175
|
+
raise RuntimeError(r["error"] or "tool failed")
|
|
176
|
+
return r["output"], None
|
|
177
|
+
if t == "agent":
|
|
178
|
+
req = {"type": "agent", "connection_id": cfg.get("connection_id"), "method": cfg.get("method", "POST"),
|
|
179
|
+
"path": _interp(cfg.get("path", ""), ctx), "headers": cfg.get("headers") or {},
|
|
180
|
+
"body": _interp_obj(cfg.get("body"), ctx)}
|
|
181
|
+
r = await dispatch.run_collect(db, req, variables, workspace_id)
|
|
182
|
+
if r["status"] == "failed":
|
|
183
|
+
raise RuntimeError(r["error"] or "agent failed")
|
|
184
|
+
return r["output"], None
|
|
185
|
+
if t == "condition":
|
|
186
|
+
# Unresolved refs become "" (not the literal "{{ref}}") so `exists`/comparisons are correct.
|
|
187
|
+
left = _interp_blank(cfg.get("left", ""), ctx)
|
|
188
|
+
right = _interp_blank(cfg.get("right", ""), ctx)
|
|
189
|
+
op = cfg.get("op", "==")
|
|
190
|
+
ok = _compare(left, right, op)
|
|
191
|
+
return {"result": ok, "left": left, "right": right, "op": op}, ("true" if ok else "false")
|
|
192
|
+
if t == "output":
|
|
193
|
+
val = _interp_obj(cfg.get("value", ""), ctx)
|
|
194
|
+
return {"value": val}, None
|
|
195
|
+
return {}, None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _registry_prompt(db, key: str, workspace_id=None):
|
|
199
|
+
"""Resolve a Prompt Registry entry's content by key within the workspace (None if missing)."""
|
|
200
|
+
from ..models import Prompt
|
|
201
|
+
q = db.query(Prompt).filter(Prompt.key == key)
|
|
202
|
+
if workspace_id is not None:
|
|
203
|
+
q = q.filter(Prompt.workspace_id == workspace_id)
|
|
204
|
+
row = q.first()
|
|
205
|
+
return row.content if row else None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _interp_blank(text, ctx):
|
|
209
|
+
"""Like _interp but resolves unknown refs to "" instead of echoing the literal template —
|
|
210
|
+
correct for condition comparisons / exists checks."""
|
|
211
|
+
if not isinstance(text, str):
|
|
212
|
+
return text
|
|
213
|
+
|
|
214
|
+
def sub(m):
|
|
215
|
+
v = _resolve(m.group(1), ctx)
|
|
216
|
+
if v is None:
|
|
217
|
+
return ""
|
|
218
|
+
return v if isinstance(v, str) else json.dumps(v)
|
|
219
|
+
return _REF.sub(sub, text)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _compare(left, right, op):
|
|
223
|
+
if op == "exists":
|
|
224
|
+
return left not in (None, "", "null")
|
|
225
|
+
if op == "contains":
|
|
226
|
+
return str(right) in str(left)
|
|
227
|
+
if op in ("==", "equals"):
|
|
228
|
+
return str(left) == str(right)
|
|
229
|
+
if op == "!=":
|
|
230
|
+
return str(left) != str(right)
|
|
231
|
+
try:
|
|
232
|
+
lf, rf = float(left), float(right)
|
|
233
|
+
return lf > rf if op == ">" else lf < rf if op == "<" else False
|
|
234
|
+
except (ValueError, TypeError):
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def run_stream(db, flow: dict, flow_input: dict, breakpoints=None, single_step=False,
|
|
239
|
+
start_at=None, ctx=None, run_id=None, variables=None, workspace_id=None):
|
|
240
|
+
breakpoints = set(breakpoints or [])
|
|
241
|
+
graph = {"nodes": flow["nodes"], "edges": flow["edges"]}
|
|
242
|
+
nodes = {n["id"]: n for n in graph["nodes"]}
|
|
243
|
+
adj = _adjacency(graph["edges"])
|
|
244
|
+
ctx = ctx or {"input": flow_input or {}, "nodes": {}}
|
|
245
|
+
rid = run_id or uuid.uuid4().hex[:12]
|
|
246
|
+
yield {"type": "start", "run_id": rid}
|
|
247
|
+
|
|
248
|
+
skey = _skey(rid, workspace_id)
|
|
249
|
+
node_id = start_at or _find_trigger(graph)
|
|
250
|
+
steps, first = ctx.get("_steps", 0), True # cumulative across pause/continue so cycles stay bounded
|
|
251
|
+
while node_id:
|
|
252
|
+
# Skip the breakpoint check only for the node we're resuming ONTO.
|
|
253
|
+
if node_id in breakpoints and not (first and start_at):
|
|
254
|
+
ctx["_steps"] = steps
|
|
255
|
+
_store_run(skey, ctx)
|
|
256
|
+
yield {"type": "pause", "node_id": node_id, "run_id": rid, "reason": "breakpoint"}
|
|
257
|
+
return
|
|
258
|
+
steps += 1
|
|
259
|
+
if steps > MAX_STEPS:
|
|
260
|
+
_drop_run(skey)
|
|
261
|
+
yield {"type": "error", "error": "step budget exceeded (cycle?)"}
|
|
262
|
+
yield {"type": "done", "status": "failed"}
|
|
263
|
+
return
|
|
264
|
+
node = nodes.get(node_id)
|
|
265
|
+
if not node:
|
|
266
|
+
break
|
|
267
|
+
ntype = node["type"]
|
|
268
|
+
title = (node.get("data") or {}).get("title") or NODE_TYPES.get(ntype, {}).get("label", ntype)
|
|
269
|
+
yield {"type": "node", "node_id": node_id, "node_type": ntype, "title": title, "status": "running"}
|
|
270
|
+
t0 = time.monotonic()
|
|
271
|
+
try:
|
|
272
|
+
output, branch = await _exec_node(db, node, ctx, variables, workspace_id)
|
|
273
|
+
except Exception as exc:
|
|
274
|
+
_drop_run(skey)
|
|
275
|
+
yield {"type": "node", "node_id": node_id, "node_type": ntype, "title": title,
|
|
276
|
+
"status": "error", "error": str(exc)[:400], "duration_ms": round((time.monotonic() - t0) * 1000)}
|
|
277
|
+
yield {"type": "done", "status": "failed"}
|
|
278
|
+
return
|
|
279
|
+
dur = round((time.monotonic() - t0) * 1000)
|
|
280
|
+
ctx["nodes"][node_id] = output
|
|
281
|
+
yield {"type": "node", "node_id": node_id, "node_type": ntype, "title": title, "status": "ok",
|
|
282
|
+
"branch": branch, "duration_ms": dur, "input": _trim(_mask_config(node.get("config") or {})),
|
|
283
|
+
"output": _trim(output)}
|
|
284
|
+
nxt = _next(adj, node_id, branch)
|
|
285
|
+
if single_step and nxt:
|
|
286
|
+
ctx["_steps"] = steps
|
|
287
|
+
_store_run(skey, ctx)
|
|
288
|
+
yield {"type": "pause", "node_id": nxt, "run_id": rid, "reason": "step"}
|
|
289
|
+
return
|
|
290
|
+
node_id = nxt
|
|
291
|
+
first = False
|
|
292
|
+
|
|
293
|
+
_drop_run(skey)
|
|
294
|
+
yield {"type": "done", "status": "completed", "output": _trim({k: v for k, v in (ctx.get("nodes") or {}).items()})}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# pop_run wraps the runstore's GETDEL-atomic pop with per-workspace key namespacing so a
|
|
298
|
+
# concurrent /continue can't resume twice and a foreign run_id can't touch another tenant.
|
|
299
|
+
__all__ = ["run_stream", "pop_run", "NODE_TYPES"]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Rate limiting + quotas, so one workspace can't exhaust shared infrastructure.
|
|
2
|
+
|
|
3
|
+
Fixed-window per-workspace counter. Redis-backed when REDIS_URL is set (correct across
|
|
4
|
+
workers), else in-memory. A dependency raises 429 with Retry-After when the window is full.
|
|
5
|
+
Local mode (no hosting) leaves the limit generous; tune via RATE_LIMIT_PER_MIN.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
|
|
14
|
+
from fastapi import Depends, HTTPException
|
|
15
|
+
|
|
16
|
+
from ..config import get_settings
|
|
17
|
+
from ..models import Workspace
|
|
18
|
+
from .workspace import current_workspace
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _MemoryWindow:
|
|
22
|
+
def __init__(self):
|
|
23
|
+
# LRU-ordered so eviction drops the oldest bucket, never the one being counted.
|
|
24
|
+
self._c: OrderedDict[str, int] = OrderedDict()
|
|
25
|
+
self._lock = threading.Lock()
|
|
26
|
+
|
|
27
|
+
def hit(self, key: str, ttl: int) -> int:
|
|
28
|
+
# Streams run concurrently in the threadpool, so the read-modify-write must be atomic
|
|
29
|
+
# or concurrent hits under-count and admit more than the limit.
|
|
30
|
+
with self._lock:
|
|
31
|
+
count = self._c.get(key, 0) + 1
|
|
32
|
+
self._c[key] = count
|
|
33
|
+
self._c.move_to_end(key) # mark as most-recently-used
|
|
34
|
+
while len(self._c) > 10_000:
|
|
35
|
+
self._c.popitem(last=False) # evict least-recently-used, keeping live buckets
|
|
36
|
+
return count
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _RedisWindow:
|
|
40
|
+
def __init__(self, url: str):
|
|
41
|
+
import redis
|
|
42
|
+
self._r = redis.Redis.from_url(url, decode_responses=True)
|
|
43
|
+
|
|
44
|
+
def hit(self, key: str, ttl: int) -> int:
|
|
45
|
+
pipe = self._r.pipeline()
|
|
46
|
+
pipe.incr(key)
|
|
47
|
+
pipe.expire(key, ttl)
|
|
48
|
+
return pipe.execute()[0]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@lru_cache
|
|
52
|
+
def _window():
|
|
53
|
+
url = get_settings().redis_url
|
|
54
|
+
return _RedisWindow(url) if url else _MemoryWindow()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _now() -> int:
|
|
58
|
+
# time.time() is fine here (not a resumable workflow); windows are wall-clock minutes.
|
|
59
|
+
return int(time.time())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def check_rate(ws: Workspace = Depends(current_workspace)) -> Workspace:
|
|
63
|
+
"""Dependency: enforce the per-workspace request rate on run endpoints."""
|
|
64
|
+
limit = get_settings().rate_limit_per_min
|
|
65
|
+
if limit <= 0:
|
|
66
|
+
return ws
|
|
67
|
+
window = _now() // 60
|
|
68
|
+
key = f"rl:{ws.id}:{window}"
|
|
69
|
+
count = _window().hit(key, 60)
|
|
70
|
+
if count > limit:
|
|
71
|
+
raise HTTPException(429, "Rate limit exceeded — slow down.",
|
|
72
|
+
headers={"Retry-After": str(60 - (_now() % 60))})
|
|
73
|
+
return ws
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check_login_rate(ident: str) -> None:
|
|
77
|
+
"""Throttle login attempts per identifier (email+IP) to blunt brute force."""
|
|
78
|
+
limit = get_settings().login_attempts_per_min
|
|
79
|
+
if limit <= 0:
|
|
80
|
+
return
|
|
81
|
+
key = f"login:{ident}:{_now() // 60}"
|
|
82
|
+
if _window().hit(key, 60) > limit:
|
|
83
|
+
raise HTTPException(429, "Too many login attempts — wait a minute.",
|
|
84
|
+
headers={"Retry-After": str(60 - (_now() % 60))})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def enforce_dataset_size(n_rows: int) -> None:
|
|
88
|
+
cap = get_settings().dataset_max_rows
|
|
89
|
+
if cap and n_rows > cap:
|
|
90
|
+
raise HTTPException(400, f"Dataset too large: {n_rows} rows (max {cap}).")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def clamp_max_tokens(req: dict) -> None:
|
|
94
|
+
cap = get_settings().max_tokens_cap
|
|
95
|
+
if cap and isinstance(req.get("max_tokens"), int) and req["max_tokens"] > cap:
|
|
96
|
+
req["max_tokens"] = cap
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def prune_runs(db, workspace_id: int) -> None:
|
|
100
|
+
"""Keep only the most recent N runs per workspace (called after an insert)."""
|
|
101
|
+
keep = get_settings().runs_retention
|
|
102
|
+
if not keep:
|
|
103
|
+
return
|
|
104
|
+
from ..models import Run
|
|
105
|
+
ids = [r.id for r in db.query(Run.id).filter(Run.workspace_id == workspace_id)
|
|
106
|
+
.order_by(Run.id.desc()).offset(keep).all()]
|
|
107
|
+
if ids:
|
|
108
|
+
db.query(Run).filter(Run.id.in_(ids)).delete(synchronize_session=False)
|
|
109
|
+
db.commit()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Masking helpers for secret values in API responses and persisted records."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
MASK = "••••••"
|
|
7
|
+
|
|
8
|
+
# Header keys whose values are secrets — masked in API responses and run history.
|
|
9
|
+
SECRET_HEADERS = {"authorization", "x-api-key", "api-key", "cookie", "x-auth-token", "proxy-authorization"}
|
|
10
|
+
# Also treat any header whose name looks credential-bearing as secret (e.g. X-Custom-Token),
|
|
11
|
+
# so masking isn't limited to the fixed set above.
|
|
12
|
+
_SECRET_HEADER_RE = re.compile(r"(authorization|api[-_]?key|secret|token|password|passwd|cookie|credential|session)", re.I)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_secret_header(name: str) -> bool:
|
|
16
|
+
n = str(name).lower()
|
|
17
|
+
return n in SECRET_HEADERS or bool(_SECRET_HEADER_RE.search(n))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def mask_value(v) -> str:
|
|
21
|
+
s = str(v)
|
|
22
|
+
return MASK + s[-4:] if len(s) > 4 else MASK
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def mask_headers(headers: dict) -> dict:
|
|
26
|
+
return {k: (mask_value(v) if is_secret_header(k) and v else v) for k, v in headers.items()}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_masked(v) -> bool:
|
|
30
|
+
return isinstance(v, str) and v.startswith(MASK)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Body fields whose values look like secrets — masked before persisting to history.
|
|
34
|
+
_SECRET_FIELDS = {"api_key", "apikey", "token", "access_token", "refresh_token", "password",
|
|
35
|
+
"secret", "client_secret", "authorization"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def mask_body(obj):
|
|
39
|
+
"""Recursively mask secret-looking fields in a request/response body for storage."""
|
|
40
|
+
if isinstance(obj, dict):
|
|
41
|
+
return {k: (mask_value(v) if k.lower() in _SECRET_FIELDS and v and isinstance(v, str)
|
|
42
|
+
else mask_body(v)) for k, v in obj.items()}
|
|
43
|
+
if isinstance(obj, list):
|
|
44
|
+
return [mask_body(x) for x in obj]
|
|
45
|
+
return obj
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Outbound URL guard (SSRF protection), enforced at every provider entry point.
|
|
2
|
+
|
|
3
|
+
Local mode (default): this is a dev tool that legitimately targets localhost and
|
|
4
|
+
private IPs, so those stay allowed — only the link-local block (incl. the
|
|
5
|
+
169.254.169.254 cloud-metadata endpoint) is refused.
|
|
6
|
+
|
|
7
|
+
Hosted mode (HOSTED=true): all private/reserved ranges are blocked, and hostnames
|
|
8
|
+
are DNS-resolved so a name pointing at an internal IP is caught too. A rebinding
|
|
9
|
+
server could still answer differently on the actual connect — pinning connections
|
|
10
|
+
through an egress proxy is the complete fix; accepted residual risk pre-GA.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ipaddress
|
|
15
|
+
import socket
|
|
16
|
+
from urllib.parse import urlparse
|
|
17
|
+
|
|
18
|
+
from ..config import get_settings
|
|
19
|
+
|
|
20
|
+
_BLOCKED_HOSTS = {"metadata", "metadata.google.internal"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BlockedURL(ValueError):
|
|
24
|
+
"""Raised when an outbound URL targets a forbidden address."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def guard_stdio() -> None:
|
|
28
|
+
"""Refuse to spawn a local MCP server process in hosted mode.
|
|
29
|
+
|
|
30
|
+
stdio MCP connections run an arbitrary `command` on the server (subprocess.Popen).
|
|
31
|
+
That is fine for a single-user local install, but on shared/hosted infrastructure it
|
|
32
|
+
is remote code execution: any authenticated tenant could run arbitrary programs. URLs
|
|
33
|
+
are policed by guard_url; this is the equivalent gate for the process transport.
|
|
34
|
+
"""
|
|
35
|
+
if get_settings().hosted:
|
|
36
|
+
raise BlockedURL("Local process (stdio) MCP connections are disabled in hosted mode")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _check_ip(ip, hosted: bool) -> None:
|
|
40
|
+
if ip.is_link_local:
|
|
41
|
+
raise BlockedURL("Link-local / metadata addresses are not allowed")
|
|
42
|
+
if hosted and not ip.is_global:
|
|
43
|
+
raise BlockedURL("Private / internal addresses are not allowed in hosted mode")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def guard_url(url: str) -> None:
|
|
47
|
+
hosted = get_settings().hosted
|
|
48
|
+
host = (urlparse(str(url)).hostname or "").lower()
|
|
49
|
+
if not host:
|
|
50
|
+
raise BlockedURL("URL has no host")
|
|
51
|
+
if host in _BLOCKED_HOSTS:
|
|
52
|
+
raise BlockedURL("Target host is not allowed")
|
|
53
|
+
|
|
54
|
+
ip = None
|
|
55
|
+
try:
|
|
56
|
+
ip = ipaddress.ip_address(host)
|
|
57
|
+
except ValueError:
|
|
58
|
+
pass # not a literal IP — a hostname
|
|
59
|
+
if ip is not None:
|
|
60
|
+
_check_ip(ip, hosted)
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
if hosted:
|
|
64
|
+
try:
|
|
65
|
+
infos = socket.getaddrinfo(host, None)
|
|
66
|
+
except OSError:
|
|
67
|
+
raise BlockedURL(f"Cannot resolve host '{host}'")
|
|
68
|
+
for info in infos:
|
|
69
|
+
_check_ip(ipaddress.ip_address(info[4][0]), hosted)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""OpenTelemetry GenAI ingest + emit.
|
|
2
|
+
|
|
3
|
+
Ingest: accept OTLP/HTTP-JSON trace exports and map gen_ai.* spans into ProveKit runs,
|
|
4
|
+
so a user SEES traces from an agent built in ANY framework (ADK, Strands, Pydantic AI,
|
|
5
|
+
CrewAI, Semantic Kernel, OpenAI Agents SDK…) without swapping in our SDK.
|
|
6
|
+
|
|
7
|
+
The gen_ai semantic conventions are still churning, so the mapping is deliberately thin
|
|
8
|
+
and accepts three dialects:
|
|
9
|
+
- current gen_ai.* v1.37+ (gen_ai.input.messages / gen_ai.output.messages,
|
|
10
|
+
gen_ai.provider.name, gen_ai.request.model, gen_ai.usage.*)
|
|
11
|
+
- legacy gen_ai.prompt / gen_ai.completion / gen_ai.system
|
|
12
|
+
- OpenInference llm.* / input.value / output.value
|
|
13
|
+
|
|
14
|
+
Emit: optionally export ProveKit's own runs as gen_ai spans to an OTLP collector, the
|
|
15
|
+
dual-export escape hatch buyers now expect.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger("provekit.otel")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _attr_value(v: dict):
|
|
26
|
+
if "stringValue" in v:
|
|
27
|
+
return v["stringValue"]
|
|
28
|
+
if "intValue" in v:
|
|
29
|
+
return int(v["intValue"])
|
|
30
|
+
if "doubleValue" in v:
|
|
31
|
+
return v["doubleValue"]
|
|
32
|
+
if "boolValue" in v:
|
|
33
|
+
return v["boolValue"]
|
|
34
|
+
if "arrayValue" in v:
|
|
35
|
+
return [_attr_value(x) for x in v["arrayValue"].get("values", [])]
|
|
36
|
+
if "kvlistValue" in v:
|
|
37
|
+
return {kv["key"]: _attr_value(kv["value"]) for kv in v["kvlistValue"].get("values", [])}
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def flatten_attrs(attributes: list) -> dict:
|
|
42
|
+
return {a["key"]: _attr_value(a.get("value") or {}) for a in attributes or []}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _first(attrs: dict, *keys):
|
|
46
|
+
for k in keys:
|
|
47
|
+
if k in attrs and attrs[k] not in (None, ""):
|
|
48
|
+
return attrs[k]
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _as_text(v) -> str:
|
|
53
|
+
if v is None:
|
|
54
|
+
return ""
|
|
55
|
+
if isinstance(v, str):
|
|
56
|
+
return v
|
|
57
|
+
try:
|
|
58
|
+
return json.dumps(v)
|
|
59
|
+
except (TypeError, ValueError):
|
|
60
|
+
return str(v)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def map_span(span: dict) -> dict | None:
|
|
64
|
+
"""Map one OTLP span to Run kwargs, or None if it isn't a gen_ai/LLM span."""
|
|
65
|
+
attrs = flatten_attrs(span.get("attributes"))
|
|
66
|
+
model = _first(attrs, "gen_ai.request.model", "gen_ai.response.model", "llm.model_name", "gen_ai.model")
|
|
67
|
+
provider = _first(attrs, "gen_ai.provider.name", "gen_ai.system", "llm.provider")
|
|
68
|
+
operation = _first(attrs, "gen_ai.operation.name")
|
|
69
|
+
tool = _first(attrs, "gen_ai.tool.name")
|
|
70
|
+
is_genai = bool(model or provider or operation or tool
|
|
71
|
+
or any(k.startswith(("gen_ai.", "llm.")) for k in attrs))
|
|
72
|
+
if not is_genai:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
prompt = _first(attrs, "gen_ai.input.messages", "gen_ai.prompt", "input.value", "llm.input_messages")
|
|
76
|
+
completion = _first(attrs, "gen_ai.output.messages", "gen_ai.completion", "output.value", "llm.output_messages")
|
|
77
|
+
usage = {}
|
|
78
|
+
it = _first(attrs, "gen_ai.usage.input_tokens", "gen_ai.usage.prompt_tokens", "llm.token_count.prompt")
|
|
79
|
+
ot = _first(attrs, "gen_ai.usage.output_tokens", "gen_ai.usage.completion_tokens", "llm.token_count.completion")
|
|
80
|
+
if it is not None:
|
|
81
|
+
usage["input_tokens"] = it
|
|
82
|
+
if ot is not None:
|
|
83
|
+
usage["output_tokens"] = ot
|
|
84
|
+
|
|
85
|
+
start = int(span.get("startTimeUnixNano") or 0)
|
|
86
|
+
end = int(span.get("endTimeUnixNano") or 0)
|
|
87
|
+
dur_ms = round((end - start) / 1e6) if end > start else 0
|
|
88
|
+
status = "failed" if (span.get("status") or {}).get("code") == 2 else "completed"
|
|
89
|
+
|
|
90
|
+
op = operation or ("execute_tool" if tool else "chat")
|
|
91
|
+
label = f"{op} {model}" if model else (span.get("name") or op)
|
|
92
|
+
meta = {"provider": provider, "model": model, "usage": usage, "source": "otel"}
|
|
93
|
+
if tool:
|
|
94
|
+
meta["tool"] = tool
|
|
95
|
+
return {
|
|
96
|
+
"type": "trace",
|
|
97
|
+
"label": label[:200],
|
|
98
|
+
"request": {"type": "trace", "provider": provider, "model": model,
|
|
99
|
+
"operation": op, "input": _as_text(prompt)[:8000]},
|
|
100
|
+
"result": {"text": _as_text(completion) or None, "output": None, "meta": meta},
|
|
101
|
+
"status": status,
|
|
102
|
+
"duration_ms": dur_ms,
|
|
103
|
+
"error": (span.get("status") or {}).get("message", "") if status == "failed" else "",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def ingest(payload: dict) -> list[dict]:
|
|
108
|
+
"""Turn an OTLP ExportTraceServiceRequest into a list of Run kwargs (gen_ai spans only)."""
|
|
109
|
+
runs = []
|
|
110
|
+
for rs in payload.get("resourceSpans", []):
|
|
111
|
+
for ss in rs.get("scopeSpans", []) + rs.get("instrumentationLibrarySpans", []):
|
|
112
|
+
for span in ss.get("spans", []):
|
|
113
|
+
mapped = map_span(span)
|
|
114
|
+
if mapped:
|
|
115
|
+
runs.append(mapped)
|
|
116
|
+
return runs
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---- emit (best-effort) ----
|
|
120
|
+
def emit_run(run_row) -> None:
|
|
121
|
+
"""Export one ProveKit run as a gen_ai span to an OTLP collector, if configured."""
|
|
122
|
+
from ..config import get_settings
|
|
123
|
+
url = get_settings().otel_export_url
|
|
124
|
+
if not url:
|
|
125
|
+
return
|
|
126
|
+
import httpx
|
|
127
|
+
|
|
128
|
+
from .netguard import guard_url
|
|
129
|
+
meta = (run_row.result or {}).get("meta") or {}
|
|
130
|
+
attrs = [{"key": "gen_ai.operation.name", "value": {"stringValue": run_row.type}}]
|
|
131
|
+
if meta.get("provider"):
|
|
132
|
+
attrs.append({"key": "gen_ai.provider.name", "value": {"stringValue": str(meta["provider"])}})
|
|
133
|
+
if meta.get("model"):
|
|
134
|
+
attrs.append({"key": "gen_ai.request.model", "value": {"stringValue": str(meta["model"])}})
|
|
135
|
+
usage = meta.get("usage") or {}
|
|
136
|
+
for src, dst in (("input_tokens", "gen_ai.usage.input_tokens"), ("output_tokens", "gen_ai.usage.output_tokens")):
|
|
137
|
+
if usage.get(src) is not None:
|
|
138
|
+
attrs.append({"key": dst, "value": {"intValue": int(usage[src])}})
|
|
139
|
+
body = {"resourceSpans": [{"scopeSpans": [{"spans": [{
|
|
140
|
+
"name": run_row.label or run_row.type, "attributes": attrs,
|
|
141
|
+
"status": {"code": 2 if run_row.status == "failed" else 1}}]}]}]}
|
|
142
|
+
try:
|
|
143
|
+
guard_url(url)
|
|
144
|
+
httpx.post(url, json=body, timeout=5, follow_redirects=False)
|
|
145
|
+
except Exception as exc: # best-effort; never break a run on export failure
|
|
146
|
+
log.debug("otel emit failed: %s", exc)
|