feat: deep-agent canvas, live observability, and multi-environment tooling
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "web",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "web", "dev"],
|
||||
"port": 3000
|
||||
},
|
||||
{
|
||||
"name": "web-dev",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "web", "exec", "next", "dev"],
|
||||
"autoPort": true,
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/.venv
|
||||
**/.data
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/.pytest_cache
|
||||
**/.ruff_cache
|
||||
**/.mypy_cache
|
||||
**/*.egg-info
|
||||
**/.git
|
||||
**/*.db
|
||||
**/*.sqlite
|
||||
**/.env
|
||||
**/.env.local
|
||||
**/.DS_Store
|
||||
# The api/worker builds now use context '.', so the old apps/api/.dockerignore is dead (Docker
|
||||
# reads only the .dockerignore at the context root). Keep tests out of the api image (nothing
|
||||
# at runtime imports them); explicit path avoids over-matching a top-level 'tests'.
|
||||
apps/api/tests
|
||||
@@ -0,0 +1,47 @@
|
||||
# Forge — environment config. Copy to `.env` at the repo ROOT (gitignored) and set real values.
|
||||
# All backend vars are prefixed FORGE_. This file holds PLACEHOLDERS ONLY — never commit real secrets.
|
||||
|
||||
# --- Core ---
|
||||
FORGE_ENVIRONMENT=development # "production" enforces the checklist at the bottom
|
||||
|
||||
# --- Platform auth ---
|
||||
FORGE_JWT_SECRET=dev-insecure-change-me # MUST be a strong random value in production
|
||||
# FORGE_AUTH_REQUIRED=true # default true — keep true so the service token is a real gate
|
||||
FORGE_BOOTSTRAP_ADMIN_EMAIL=you@forge.local
|
||||
FORGE_BOOTSTRAP_ADMIN_PASSWORD=forge-admin # change for production
|
||||
|
||||
# --- Server-to-server barrier (an app backend → Forge) ---
|
||||
# Static bearer that authenticates a trusted backend as a least-privilege service identity.
|
||||
# Generate: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
FORGE_SERVICE_API_TOKEN=
|
||||
|
||||
# --- SSRF egress: allow specific private/loopback hosts (dev/testing only) ---
|
||||
FORGE_EGRESS_ALLOW_PRIVATE_HOSTS=[] # e.g. ["localhost","127.0.0.1"] (Docker: ["host.docker.internal"])
|
||||
|
||||
# --- Tools: per-environment values ---
|
||||
# A JSON map exposed to REST/GraphQL tool + auth templates as {{env.*}}, so the SAME tool row
|
||||
# resolves to a different host per deploy (dev/qa/prod). A template referencing a key NOT in this
|
||||
# map fails the call loudly. Blank/unset = {}.
|
||||
FORGE_TOOL_VARS= # e.g. {"api_base":"https://api.example.com"}
|
||||
# Deployment-wide fallback for a per-user auth provider's `token_ctx_key`: the run-context key an
|
||||
# integration forwards its per-user token under (via X-Forge-Context) when a provider doesn't set
|
||||
# its own. Empty = off. e.g. user_token
|
||||
FORGE_DEFAULT_TOKEN_CTX_KEY=
|
||||
|
||||
# --- Model (needed for live agent runs) ---
|
||||
FORGE_DEFAULT_MODEL=fake:echo # offline-safe; set a real model for live runs, e.g. gpt-4.1-mini
|
||||
# OPENAI_API_KEY=sk-...
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# --- Frontend ---
|
||||
NEXT_PUBLIC_FORGE_API_URL=http://localhost:8000
|
||||
|
||||
# ============================================================================
|
||||
# PRODUCTION (Docker stack) — uncomment/set when deploying:
|
||||
# POSTGRES_PASSWORD=<db-password> # compose substitutes this into the DB URL
|
||||
# FORGE_DATABASE_URL=postgresql+psycopg://forge:<pw>@localhost:5432/forge
|
||||
# FORGE_CHECKPOINT_BACKEND=postgres
|
||||
# FORGE_REDIS_URL=redis://localhost:6379/0
|
||||
# The app REFUSES to boot in production unless: strong FORGE_JWT_SECRET,
|
||||
# FORGE_AUTH_REQUIRED=true, FORGE_EGRESS_BLOCK_PRIVATE=true, Postgres FORGE_DATABASE_URL.
|
||||
# ============================================================================
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report something that isn't working as expected
|
||||
title: "[bug] "
|
||||
labels: bug
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what's wrong.
|
||||
|
||||
**To reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '…'
|
||||
2. Configure '…'
|
||||
3. Run '…'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
What you expected to happen.
|
||||
|
||||
**Screenshots / logs**
|
||||
If applicable, add screenshots or relevant log output. **Redact any secrets, tokens, or
|
||||
customer data.**
|
||||
|
||||
**Environment**
|
||||
- Forge version / commit SHA:
|
||||
- Deployment: local (SQLite) / Docker (Postgres) / other
|
||||
- OS & browser (for console issues):
|
||||
- Python / Node version (for local dev):
|
||||
|
||||
**Additional context**
|
||||
Anything else that might help — workflow/tool config (redacted), provider, etc.
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 🔒 Report a security vulnerability
|
||||
url: https://github.com/nihalashetty/Forge/security/advisories/new
|
||||
about: Please report vulnerabilities privately via a GitHub Security Advisory, not a public issue.
|
||||
- name: 💬 Questions & discussion
|
||||
url: https://github.com/nihalashetty/Forge/discussions
|
||||
about: Ask questions and share ideas with the community.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea or improvement
|
||||
title: "[feat] "
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
**What problem are you trying to solve?**
|
||||
A clear description of the use case or pain point. "I'm always frustrated when …"
|
||||
|
||||
**Proposed solution**
|
||||
What you'd like Forge to do.
|
||||
|
||||
**Alternatives considered**
|
||||
Other approaches or workarounds you've thought about.
|
||||
|
||||
**Additional context**
|
||||
Mockups, links, related nodes/tools, or examples. Check the
|
||||
[roadmap](../../docs/ROADMAP.md) first — it may already be planned.
|
||||
@@ -0,0 +1,27 @@
|
||||
<!-- Thanks for contributing to Forge! Please fill this out to speed up review. -->
|
||||
|
||||
## What & why
|
||||
|
||||
<!-- What does this change do, and what problem does it solve? Link the issue: Closes #123 -->
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Performance
|
||||
- [ ] Refactor (no behavior change)
|
||||
- [ ] Docs / chore
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Backend: `ruff check forge migrations` is clean and `pytest -q` passes (from `apps/api`)
|
||||
- [ ] New/changed backend code is `mypy`-clean
|
||||
- [ ] Frontend: `pnpm --filter web build` passes (from repo root)
|
||||
- [ ] Shared schemas (`packages/schemas`) updated if node/tool config changed
|
||||
- [ ] Tests added/updated for the change (characterization test for behavior-preserving refactors)
|
||||
- [ ] `CHANGELOG.md` updated under **Unreleased** (for user-facing changes)
|
||||
- [ ] No secrets, tokens, or customer data in the diff
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
<!-- Anything that needs special attention, screenshots for UI changes, migration notes, etc. -->
|
||||
@@ -0,0 +1,30 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Backend Python dependencies (PEP 621 pyproject.toml).
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/apps/api"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
python:
|
||||
patterns: ["*"]
|
||||
|
||||
# Frontend / workspace JS dependencies (pnpm workspace at the repo root).
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
javascript:
|
||||
patterns: ["*"]
|
||||
|
||||
# GitHub Actions used by the workflows.
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
actions:
|
||||
patterns: ["*"]
|
||||
@@ -0,0 +1,51 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
# Least privilege: CI only reads the checked-out code; it never writes back to the repo.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api:
|
||||
name: API - lint + tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/api
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[dev,all]"
|
||||
- name: Ruff
|
||||
run: ruff check forge migrations
|
||||
- name: Mypy (advisory — gradual typing, see CONTRIBUTING.md)
|
||||
continue-on-error: true
|
||||
run: mypy forge
|
||||
- name: Pytest
|
||||
run: pytest -q
|
||||
|
||||
web:
|
||||
name: Web - typecheck + build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: pnpm
|
||||
- name: Install
|
||||
run: pnpm install --frozen-lockfile || pnpm install
|
||||
- name: Build
|
||||
run: pnpm --filter web build
|
||||
@@ -0,0 +1,34 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # weekly, Monday 06:00 UTC
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
actions: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [python, javascript-typescript]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
language: ${{ matrix.language }}
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# --- Python ---
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# --- Node / Next ---
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
.turbo/
|
||||
|
||||
# --- Forge runtime data & secrets ---
|
||||
apps/api/.data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
.env
|
||||
.env.local
|
||||
master.key
|
||||
|
||||
# --- Editors / OS / tooling ---
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# --- Claude Code / agent tooling (local dev only; not part of the product) ---
|
||||
# Keep .claude/launch.json (the preview config) tracked; ignore everything else.
|
||||
.agents/
|
||||
.claude/*
|
||||
!.claude/launch.json
|
||||
|
||||
# --- WebFetch / temp artifacts ---
|
||||
*.bin
|
||||
|
||||
# --- Local architecture notes (not part of the product) ---
|
||||
/PENDING_FEATURES.md
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Forge are documented here.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
|
||||
project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Deep agents on canvas, live observability & multi-environment tools
|
||||
- **Deep-agent sub-agents on the canvas** (new): a Deep Agent node gains a third **subagents** handle —
|
||||
drag it to any specialist agent node to fold that node in as a callable sub-agent (rendered as a
|
||||
dashed org-chart branch). The compiler folds each wired agent into the supervisor's `subagents`
|
||||
(name/description/system_prompt/tools/model), and the deep agent is now built as `create_agent` +
|
||||
only the opt-in deepagents middleware you enable (planning / filesystem / sub-agents / skills)
|
||||
instead of the full `create_deep_agent` harness — a concise task-tool prompt replaces deepagents'
|
||||
~536-token essay, cutting per-supervisor-turn cost. Skills stay wired via `SkillsMiddleware`.
|
||||
- **Live agent-activity + named sub-agent traces** (new): the tracer records a deep-agent `task`
|
||||
dispatch as a named `subagent · <name>` span (kind `subagent`) instead of a generic `tool · task`,
|
||||
and streams a per-tool/per-sub-agent **activity** timeline over SSE. The Playground shows this live
|
||||
("Agent activity"), the canvas Test panel lights up folded sub-agent nodes, and the **Traces** view
|
||||
is now a **collapsible span tree** (real `parent_span_id` hierarchy, friendly canvas node names,
|
||||
per-kind colored dots, collapse/expand-all).
|
||||
- **Per-environment tool values `{{env.*}}`** (new): a `FORGE_TOOL_VARS` JSON map is exposed to
|
||||
REST/GraphQL tool + auth endpoint templates as `{{env.*}}`, so the SAME tool row resolves to a
|
||||
different host per deploy (dev/qa/prod). Missing keys **fail loud** (never a broken URL); `{{ctx.*}}`
|
||||
stays lenient.
|
||||
- **Protected, auto-provisioned built-ins**: the platform built-ins (time, calculator, web
|
||||
fetch/search, knowledge search, `remember`/`recall`) are provisioned into every project on create
|
||||
and on tools-list read, pinned to the top of the Tools screen, and **cannot be deleted** (409). They
|
||||
are excluded from import/export bundles, so importing a project neither duplicates nor loses them.
|
||||
- **Import / export now carries tool sets**: a tool bundle includes the tool sets grouping its tools;
|
||||
import re-creates them (remapping ids, auto-renaming on collision) and reports the count.
|
||||
- **Live-streamed evaluations**: dataset runs stream `start` / `item` / `done` SSE frames — every case
|
||||
renders immediately and resolves live with per-case status, latency, and tokens, plus a progress bar.
|
||||
- **Console runs act as the logged-in operator**: Playground and canvas-test runs are attributed to
|
||||
the signed-in user (removing the manual "Acting as" box), so per-user auth providers resolve the
|
||||
operator's own connected credential — the same on-behalf-of path evals now use.
|
||||
- **`FORGE_DEFAULT_TOKEN_CTX_KEY`** (new): a deployment-wide fallback for a per-user auth provider's
|
||||
`token_ctx_key`, so an integration that always forwards its per-user token under one context key
|
||||
works for every provider without per-provider configuration.
|
||||
- **Fixed:** OpenAI streamed runs now report token usage / cost (`stream_usage`), instead of 0; a HITL
|
||||
turn with several approval-gated tool calls resumes correctly (one decision replicated to each
|
||||
hanging call) in both workflow runs and the Forge Assistant; the MCP endpoint shown on the Deploy
|
||||
screen points at the API host directly so OAuth discovery works.
|
||||
|
||||
### Tool sets, MCP server, governance & portability
|
||||
- **Tool sets** (new): a describable, many-to-many group of tools that does two jobs at once —
|
||||
it organizes the Tools screen (folders/filter chips) *and* is the unit of assignment and
|
||||
exposure. Grant an agent a whole set (`agent.config.toolsets`, resolved to member tools at
|
||||
compile time) and publish a set as a GitHub-style **MCP toolset**.
|
||||
- **Per-project MCP server — full transport + auth.** The exposed server now speaks native
|
||||
**Streamable-HTTP / SSE** (Claude Desktop, Cursor, VS Code connect **directly — no `mcp-remote`
|
||||
bridge**), with the legacy request/response JSON-RPC POST preserved for simple clients; both
|
||||
share one auth + tool-resolution core. Three ways to authenticate: a shared **project API key**
|
||||
(server-to-server, no identity), a per-user **personal access token** (PAT, `forge_pat_…`), and
|
||||
optional **OAuth 2.1** (Dynamic Client Registration + PKCE S256, audience-bound tokens;
|
||||
default-off behind `FORGE_MCP_OAUTH_ENABLED`). The exposed surface is exactly the enabled tools
|
||||
of **exposed tool sets**; knowledge, Q&A, and a whole workflow can also be published as MCP
|
||||
tools. A least-privileged **connector** role can self-serve MCP tokens and call tools but sees
|
||||
no projects/settings.
|
||||
- **Per-user identity over MCP + connected credentials.** A project-scoped session token or PAT
|
||||
resolves to an `end_user`, threaded into the run so entitlement gating and `{{ctx.*}}` injection
|
||||
act per user. An OAuth auth-provider can key its token bundle **per end user**
|
||||
(`per_user_context_keys`); the app owner stores each user's bundle via the new **connections API**
|
||||
(`PUT/GET/DELETE /v1/projects/{id}/auth-providers/{apId}/connections/{endUserId}`). No MCP token
|
||||
is ever passed downstream — Forge holds a separate per-user credential.
|
||||
- **Guardrails & Egress** (new): a single project-level I/O policy in **Settings → Guardrails &
|
||||
Egress** (admin-gated), enforced **by default on every agent** — no per-agent wiring. Content
|
||||
guardrails (PII redaction, custom `Label = regex` patterns, blocked terms with
|
||||
redact/mask/hash/block/flag) run locally in-process; the network egress policy (block-private +
|
||||
allow/deny domain lists) is applied to every REST/GraphQL tool, webhook, `web_fetch`, and SQL
|
||||
host. A project may only **tighten** inherited egress, never loosen it.
|
||||
- **Import / Export (portability)** (new): export **tools, workflows, components, and agents** to a
|
||||
portable `forge.bundle/1` JSON file and import them into another project. Secret **values never
|
||||
leave** (only `secret://…` references travel; import warns you to recreate them); imports **never
|
||||
overwrite** (new ids, auto-rename on name collision) and remap in-bundle references. Available
|
||||
from each list screen's toolbar; import requires the `editor` role.
|
||||
- **Model catalog from the backend**: the provider/model list is served by the API as one source
|
||||
of truth (was duplicated in the web app).
|
||||
|
||||
### Changed (console & runtime)
|
||||
- **Console reskin**: a shadcn-style **neutral + indigo** design system and a minimal sidebar nav;
|
||||
Traces now read like a chat history; unified screen headings and bare icons app-wide.
|
||||
- **Performance:** cut interactive chat latency by eliminating a ~4s cold-connection DNS (AAAA)
|
||||
stall on outbound LLM/REST calls and reusing pooled LLM connections
|
||||
(`FORGE_PREFER_IPV4_EGRESS`, default on).
|
||||
|
||||
### Fixed (this cycle)
|
||||
- Map `openai_moderation` middleware flags to `langchain-openai >=1.3`.
|
||||
- Group a HITL pause+resume into a single trace turn, and stop recording a HITL interrupt as a
|
||||
span error.
|
||||
- Classifier sends one bounded human turn for cross-provider compatibility; the agent binds at
|
||||
most one tool per function name.
|
||||
|
||||
### Feature-bounty fixes (correctness, governance, and DX)
|
||||
- **Entity version history** (new): every save of a workflow/agent/tool/component/auth-provider/
|
||||
knowledge-source/project snapshots to `entity_versions`; view + restore in the console; retention
|
||||
pruned to a configurable `version_history_limit`.
|
||||
- **Engine correctness:** Loop nodes no longer crash past ~8 iterations (run `recursion_limit` is
|
||||
set); many previously-ignored node/middleware options now work (Join reducer, parallel
|
||||
isolation/ordering/timeout, tenant-budget USD cap + per-run token scope, guardrail
|
||||
apply_to/redact/flag, model_retry retry_on, subworkflow input/output mapping, Transform jq, LLM
|
||||
`{{state}}` templating, agent-node dynamic prompt/model); validation now errors on undeclared
|
||||
state-key writes + branches-without-condition.
|
||||
- **RAG grounding:** default relevance floor calibrated to the local embedder (0.18 → 0.6),
|
||||
thresholds the true cosine in hybrid mode + a rerank floor (so off-topic → "I don't know"),
|
||||
chunk citations, per-page crawl provenance (+robots/limits), MMR, resumable batched ingest.
|
||||
- **Isolation/privacy:** per-user long-term memory scope; response-cache keyed by tenant/user/auth;
|
||||
Postgres RLS actually wired (per-transaction tenant GUC); MCP `stdio` gated + external-MCP SSRF
|
||||
screening; tool-I/O trace redaction on by default in production.
|
||||
- **Reliability:** webhook + `/run` idempotency; scheduler on by default with an atomic
|
||||
double-fire-safe claim; HITL TOCTOU + chained-interrupt + timeout fixes; outbound channel retry
|
||||
with delivery status; run cancellation; transient-only tool retries.
|
||||
- **Observability:** OTel export fixed (wall-clock times + real span hierarchy); cost accounting
|
||||
handles prompt-cache tiers + dated/unlisted models; retriever/embedding spans; evals gain
|
||||
concurrency, persisted history + regression gate, more scorers, robust judge.
|
||||
- **Platform/governance:** per-project RBAC + scoped revocable API keys; auth rate-limiting,
|
||||
refresh rotation, logout, password-reset/verify, optional TOTP MFA; project budgets +
|
||||
allowed-models enforcement; scheduled retention purge; audit pagination/export + secret.read;
|
||||
fail-closed public rate limiter; extended hardening guard; workspace management; worker DLQ.
|
||||
- **MCP:** exposed-server rate-limited + per-project tool allow-list; expose a whole workflow as
|
||||
an MCP tool (`mcp_expose_workflow`).
|
||||
- **Semantic caching** wired as an agent middleware (was built but unreachable).
|
||||
- **Console:** Settings redesigned with a section sidebar (incl. a model-pricing editor); a
|
||||
restrained de-colored palette; version-history drawer; canvas unsaved-changes guard + undo/redo
|
||||
+ copy-paste; Playground Stop + real thread reset; Deep Agent config panel.
|
||||
|
||||
### Added
|
||||
- Project developer meta: `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, this
|
||||
changelog, and GitHub issue/PR templates.
|
||||
- Lightweight `GET /v1/projects/{id}/counts` endpoint powering the sidebar badge counts.
|
||||
- In-flight GET de-duplication in the web API client (collapses duplicate concurrent
|
||||
requests into one).
|
||||
- Characterization tests for the stats rollups (`apps/api/tests/test_stats.py`).
|
||||
- Static type-checking with `mypy` (advisory in CI; gradual adoption) and a `CodeQL`
|
||||
workflow + Dependabot for supply-chain updates.
|
||||
|
||||
### Changed
|
||||
- **Performance:** dashboard and project stats now compute rollups as SQL aggregates
|
||||
(`COUNT`/`SUM` + `GROUP BY`) instead of loading a tenant's entire trace history into
|
||||
memory. Output is unchanged.
|
||||
- **Performance:** the project sidebar fetches one counts call instead of six full lists;
|
||||
the dashboard fetches its stats once (was twice); Overview reuses counts.
|
||||
- **Performance:** the Traces view loads conversations 20 at a time with infinite scroll
|
||||
instead of fetching the entire history at once.
|
||||
- Typed API responses for the counts and stats endpoints (`response_model`), improving the
|
||||
generated OpenAPI schema.
|
||||
- Pinned `ruff` to a reproducible range so CI lint doesn't drift with new rule sets.
|
||||
|
||||
### Fixed
|
||||
- Documentation drift: backend README layout, root README architecture description,
|
||||
`TECH_STACK.md` embedder entry, and roadmap chunking strategies.
|
||||
- Pre-existing lint findings (import order, statement style, mutable `ContextVar` default).
|
||||
|
||||
### Removed
|
||||
- Dead code: an orphaned frontend screen and a half-wired "code" workflow node (frontend
|
||||
palette entry + orphan schema with no backend registration).
|
||||
- Committed local agent tooling that was not part of the product.
|
||||
|
||||
## [0.1.0]
|
||||
|
||||
- Initial Forge platform: visual agent/workflow builder on LangChain + LangGraph, tools
|
||||
(REST/GraphQL/Code/SQL/MCP/built-in), knowledge & RAG, generative-UI components,
|
||||
embeddable widget, channels, triggers, evaluations, observability/traces, and a
|
||||
production-shaped Docker stack. See the [README](README.md) and [ROADMAP](docs/ROADMAP.md).
|
||||
|
||||
[Unreleased]: https://github.com/nihalashetty/Forge/compare/main...HEAD
|
||||
@@ -0,0 +1,42 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our commitment
|
||||
|
||||
We want Forge to be a welcoming, harassment-free community for everyone, regardless of
|
||||
experience level, background, or identity. We are committed to a respectful and
|
||||
constructive environment for all contributors and users.
|
||||
|
||||
## Expected behavior
|
||||
|
||||
- Be respectful, considerate, and collaborative.
|
||||
- Welcome newcomers and assume good intent.
|
||||
- Give and gracefully accept constructive feedback; critique ideas, not people.
|
||||
- Focus on what is best for the community and the project.
|
||||
|
||||
## Unacceptable behavior
|
||||
|
||||
- Harassment, insults, or derogatory comments; personal or political attacks.
|
||||
- Discriminatory language or imagery, or unwelcome sexual attention.
|
||||
- Publishing others' private information without explicit permission.
|
||||
- Sustained disruption of discussions, issues, or reviews.
|
||||
|
||||
## Scope
|
||||
|
||||
This code applies in all project spaces — the repository, issues, pull requests,
|
||||
discussions — and when an individual is representing the project in public spaces.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive or unacceptable behavior may be reported to the maintainers privately
|
||||
via a [GitHub Security Advisory](https://github.com/nihalashetty/Forge/security/advisories/new)
|
||||
or by contacting a maintainer directly. All reports will be reviewed and investigated
|
||||
promptly and fairly, and reporter confidentiality will be respected.
|
||||
|
||||
Maintainers may take any action they deem appropriate, up to and including a temporary or
|
||||
permanent ban from the community, and will communicate the reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
---
|
||||
|
||||
This Code of Conduct is adapted in spirit from the
|
||||
[Contributor Covenant](https://www.contributor-covenant.org/).
|
||||
@@ -0,0 +1,74 @@
|
||||
# Contributing to Forge
|
||||
|
||||
Thanks for your interest in improving Forge! This guide covers how to get set up, the
|
||||
checks your change needs to pass, and our conventions.
|
||||
|
||||
## Ways to contribute
|
||||
|
||||
- **Report bugs** and **request features** via [issues](https://github.com/nihalashetty/Forge/issues) (templates provided).
|
||||
- **Improve docs** — the [User Manual](docs/MANUAL.md), READMEs, and `TECH_STACK.md`.
|
||||
- **Fix or build** — pick up an open issue or propose a change in a discussion first for anything large.
|
||||
|
||||
## Development setup
|
||||
|
||||
See the [README quick start](README.md#quick-start-local-zero-infra) for the full local
|
||||
(zero-infra) setup. In short:
|
||||
|
||||
```bash
|
||||
# Backend (FastAPI engine)
|
||||
cd apps/api
|
||||
python -m venv .venv && source .venv/bin/activate # .venv\Scripts\activate on Windows
|
||||
pip install -e ".[dev,all]"
|
||||
|
||||
# Frontend (Next.js console) — from the repo root
|
||||
pnpm install
|
||||
pnpm --filter web dev
|
||||
```
|
||||
|
||||
The local stack runs on SQLite + embedded Chroma + an in-process scheduler — no Docker,
|
||||
Postgres, or Redis required. See [`apps/api/README.md`](apps/api/README.md) for the backend
|
||||
layout and production swaps.
|
||||
|
||||
## Before you open a pull request
|
||||
|
||||
Run the same checks CI runs:
|
||||
|
||||
**Backend** (from `apps/api`):
|
||||
```bash
|
||||
ruff check forge migrations # lint (pinned; must be clean)
|
||||
mypy forge # type check (advisory today — keep new/changed code clean)
|
||||
pytest -q # tests must pass
|
||||
```
|
||||
|
||||
**Frontend** (from the repo root):
|
||||
```bash
|
||||
pnpm --filter web build # typecheck + build
|
||||
```
|
||||
|
||||
Additional expectations:
|
||||
|
||||
- **Keep the shared schemas authoritative.** `packages/schemas` is the single source of
|
||||
truth for node/tool config; the backend validator, the compiler, and the frontend
|
||||
`<SchemaForm>` all read from it. Update the schema, not one consumer.
|
||||
- **Add tests** for new behavior. For refactors that must preserve behavior, add a
|
||||
characterization test first (see `apps/api/tests/test_stats.py` for the pattern).
|
||||
- **Type-checking is gradual.** `mypy` is advisory in CI while we clear a backlog on the
|
||||
older engine modules, but any file you add or substantially change should be mypy-clean.
|
||||
|
||||
## Commit & PR conventions
|
||||
|
||||
- **Conventional Commits** for messages: `feat:`, `fix:`, `perf:`, `chore:`, `docs:`,
|
||||
`refactor:`, `test:` — optionally scoped, e.g. `perf(stats): …`.
|
||||
- **Atomic commits** — one logical change per commit; keep history bisectable.
|
||||
- Open a PR describing the change and the reasoning. Link the issue it addresses.
|
||||
- Update [`CHANGELOG.md`](CHANGELOG.md) under **Unreleased** for anything user-facing.
|
||||
|
||||
## Versioning
|
||||
|
||||
Forge follows [Semantic Versioning](https://semver.org/). User-facing changes are recorded
|
||||
in the changelog and rolled into the next release.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions are licensed under the
|
||||
[MIT License](LICENSE), the same license as the project.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Nihal A Shetty
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,238 @@
|
||||
<div align="center">
|
||||
|
||||
# Forge
|
||||
|
||||
**The open-source, self-hosted platform for visually building, testing, and shipping AI agents & workflows.**
|
||||
|
||||
Wire agents, tools, knowledge, and logic on a canvas - ground them in your data, connect them to your systems, and deploy to email, an API, an MCP server, or an embeddable web widget. No framework code required.
|
||||
|
||||
[](LICENSE)
|
||||
[](apps/api/pyproject.toml)
|
||||
[](apps/web/package.json)
|
||||
[](apps/api)
|
||||
[](apps/web)
|
||||
[](https://github.com/langchain-ai/langchain)
|
||||
[](#contributing)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
Forge is built directly on the **MIT-licensed LangChain v1 + LangGraph v1** framework - and **never** depends on `langgraph-api` (Elastic 2.0) or LangSmith (commercial). Everything you orchestrate runs on your own infrastructure; nothing is sent to a third-party orchestration service.
|
||||
|
||||
- **Fully open source (MIT).** No proprietary core, no usage caps, no vendor lock-in.
|
||||
- **Zero-infra local dev.** Boots on SQLite + embedded Chroma + an in-process scheduler - no Docker, Postgres, or Redis required to start.
|
||||
- **Production-ready.** Swap to Postgres + pgvector, Redis, and a real secret store with config only - a hardening guard refuses to boot with insecure defaults.
|
||||
- **Observable by default.** Every run is a span waterfall with tokens, latency, and cost down to fractions of a cent.
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Architecture](#architecture)
|
||||
- [Quick start](#quick-start-local-zero-infra)
|
||||
- [Run with Docker](#run-with-docker-production-shaped)
|
||||
- [Documentation](#documentation)
|
||||
- [Tech stack](#tech-stack)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
## Features
|
||||
|
||||
<div align="center">
|
||||
<video src="https://github.com/user-attachments/assets/798a6872-0a47-455f-81be-4566184e3e9c" controls muted width="85%"></video>
|
||||
</div>
|
||||
|
||||
> **[Watch the demo](docs/media/Forge_demo.mp4)** - the in-product **Forge Assistant** builds and runs a workflow end to end. *(If the player doesn't load inline, click the link to play.)*
|
||||
|
||||
### Visual Workflow Builder
|
||||
|
||||
Wire an entire app on a **drag-and-drop canvas** (React Flow): drop nodes from the palette - **agents** & deep agents, model calls, classifiers, tools, transforms, retrieval, human input/handoff, routers, loops, parallel fan-out/join, subworkflows, and triggers - and connect them with **typed, validated** edges. A per-node inspector and a live **state schema** keep runs type-safe, while a minimap, undo/redo, and copy/paste keep big graphs manageable. **Save**, **Test**, or open the **Playground** to watch nodes light up as the run streams - then **Publish**.
|
||||
|
||||
<p align="center"><img src="docs/media/Workflow.png" alt="Forge visual workflow builder: a React Flow canvas wiring retrieval, router, agent, and end nodes, with a node palette, a state-schema inspector, and a minimap" width="90%"></p>
|
||||
|
||||
### Visual Agent Builder
|
||||
|
||||
Compose an **Agent** or a **Deep Agent** (planning + subagents for long multi-step tasks) from a model, a system prompt, tools, knowledge, Q&A, and a reorderable **middleware stack** - all from friendly forms, no JSON. A live *"what the model sees"* panel shows the exact compiled prompt and middleware execution order before you ship. Build a **supervisor** visually: drag from a Deep Agent's **subagents** handle to any specialist agent node and it folds in as a callable sub-agent (an org-chart branch on the canvas) - each with its own model, tools, and prompt, dispatched via the `task` tool and shown as named sub-agent spans in the trace.
|
||||
|
||||
<p align="center"><img src="docs/media/Agents.png" alt="Forge visual agent builder with model, instructions, tools, and a 'what the model sees' panel" width="90%"></p>
|
||||
|
||||
### Tool Builder with Response Projection
|
||||
|
||||
Register **REST, GraphQL, Code, SQL, MCP, or built-in** tools and test them live against real inputs. A **JMESPath response projection** trims bulky payloads *before* they reach the model - watch the raw → projected **token meter** shrink in real time to control cost. Every outbound call is screened by an **SSRF guard**, with optional retries, rate limits, and caching. Point a tool's endpoint at a **per-environment value** with `{{env.*}}` (from `FORGE_TOOL_VARS`) so the same tool row resolves to your dev / qa / prod host per deploy. The platform **built-ins** (time, calculator, web fetch/search, knowledge search, memory) are auto-provisioned into every project and protected from deletion. Organize tools into **tool sets** - reusable, many-to-many groups that double as folders on the screen, get granted to an agent in one click, and publish as MCP toolsets.
|
||||
|
||||
<p align="center"><img src="docs/media/Tools.png" alt="Forge tool builder with request config, live response, and a raw-to-projected token cost meter" width="90%"></p>
|
||||
|
||||
The Tools screen groups everything by set (switchable between grid and list), with one-click **export / import** to move tools between projects:
|
||||
|
||||
<p align="center"><img src="docs/media/Tools_dashboard.png" alt="Forge Tools screen: the tool grid grouped into reusable tool sets, with grid/list views and one-click export and import" width="90%"></p>
|
||||
|
||||
### Knowledge & RAG
|
||||
|
||||
Ground agents in **your own data**. Add pasted text, URLs, crawled sites, or uploaded files (`.txt/.md/.csv/.json/.html/.pdf`); Forge chunks, embeds (**offline-capable by default**), and stores them as vectors organized in folders. Curated **Q&A pairs** deflect common questions, and a **search debugger** lets you inspect exactly what retrieval returns.
|
||||
|
||||
<p align="center"><img src="docs/media/Knowledge.png" alt="Forge knowledge screen showing document sources, folders, chunking status, and Q&A" width="90%"></p>
|
||||
|
||||
The built-in **search debugger** plots every chunk by semantic similarity and overlays a query - so you can see exactly which chunks a search retrieves, and why:
|
||||
|
||||
<p align="center"><img src="docs/media/Knowledge_storage_debugger.png" alt="Forge knowledge search debugger: a PCA chunk map colored by source, with a query overlay marking the retrieved chunks" width="90%"></p>
|
||||
|
||||
### Generative UI Components
|
||||
|
||||
Let agents render **rich, interactive UI** - tables, cards, forms - instead of plain text. Author an HTML/CSS component once with a live preview; the model emits a tiny payload while the markup renders in a **sandboxed iframe** and never bloats the token stream. Buttons can send structured actions straight back to the agent.
|
||||
|
||||
<p align="center"><img src="docs/media/Components.png" alt="Forge component builder with HTML/CSS editor and a live sandboxed preview of a weather card" width="90%"></p>
|
||||
|
||||
### Embeddable Web Widget
|
||||
|
||||
Drop your assistant onto **any website** with a one-line script - a floating chat bubble locked to the origins you allow. End users see only the conversation; operational details (steps, tokens, cost, node names) stay private in the dashboard.
|
||||
|
||||
<p align="center"><img src="docs/media/Embeddings.png" alt="Forge embed screen with widget toggle, allowed origins, and a copy-paste launcher snippet" width="90%"></p>
|
||||
|
||||
### Deploy anywhere - one run API, MCP & channels
|
||||
|
||||
Ship the same workflow through many surfaces without rewriting it: call it server-to-server over a **single run API** (`POST /run` handles new turns, streaming, and human-in-the-loop resumes), expose it as an **MCP server**, deploy it to **email**, or drop in the **web widget**. Per-request caller context (`X-Forge-Context`) lets tools act on behalf of your end users - with secrets never in the request body.
|
||||
|
||||
<p align="center"><img src="docs/media/Integrations.png" alt="Forge Connect screen showing the run API: the Forge API base URL, the POST /run endpoint, and a copy-paste curl example" width="90%"></p>
|
||||
|
||||
> [!NOTE]
|
||||
> **Connectors are not yet fully implemented.** The prebuilt, one-click **connector library / marketplace** (Google, Slack, Notion, GitHub, Salesforce, …) is on the [roadmap](docs/ROADMAP.md#planned--exploring) — it is **not shipped yet**. Today you integrate an external system by hand: create a **REST / GraphQL / MCP tool** and pair it with an **Auth Provider** (Bearer / API key / Basic / OAuth2 / CSRF-session). *(Note: the `connector` **role** — a least-privileged MCP-only user — is a separate, shipped feature and unrelated to the connector library.)*
|
||||
|
||||
### Observability & Traces
|
||||
|
||||
Every run is captured as a **collapsible span tree** - model calls, tools, chains, sub-agents, latency, tokens, and **cost**, nested by real parent/child so you can see exactly what happened and what it cost. Deep-agent dispatches appear as named `subagent · <name>` spans, and the Playground streams a **live agent-activity** timeline (which sub-agent/tool ran, as it happens). Pair it with **Evaluations** to catch regressions before you publish, and export traces to any **OpenTelemetry** collector (e.g. Langfuse).
|
||||
|
||||
<p align="center"><img src="docs/media/Traces.png" alt="Forge traces screen showing a run's span waterfall with per-step latency, tokens, and cost" width="90%"></p>
|
||||
|
||||
### Guardrails, budgets & governance
|
||||
|
||||
Run it like production. A project-level **Guardrails & Egress** policy (PII redaction, blocked terms, and a network allow/deny list) applies to every agent by default; **budgets & quotas** cap spend and tokens; **versioning** snapshots every change; and it all sits behind per-project **roles / RBAC** and an audit log - from one Settings surface.
|
||||
|
||||
<p align="center"><img src="docs/media/Settings.png" alt="Forge project Settings with a section sidebar: General, Members and Roles, API Keys, Model Pricing, Budgets and Quotas, Guardrails and Egress, Versioning, and more" width="90%"></p>
|
||||
|
||||
### And many more
|
||||
|
||||
- **Channels** - deploy a workflow to **Email**.
|
||||
- **Triggers** - webhooks, schedules (interval/cron), inbound email, and polling "app events".
|
||||
- **Human-in-the-loop** - approve/reject pauses and live **handoff** to an Agent inbox, with the reply delivered back over the same channel.
|
||||
- **Auth Providers** - Bearer, API key, Basic, OAuth2 (client-credentials **and** 3-legged user login with auto-refresh), and CSRF/session - backed by encrypted, reference-only secrets (`secret://…`).
|
||||
- **MCP, both ways** - expose your tools as an **MCP server** over native Streamable-HTTP/SSE (Claude Desktop, Cursor, and VS Code connect directly - no bridge), authenticated by a project key, per-user **personal access tokens**, or optional **OAuth 2.1**, publishing tool sets, knowledge, Q&A, or a whole workflow; and consume tools from external MCP servers.
|
||||
- **Guardrails & egress policy** - one project-level I/O policy (PII redaction, blocked terms, and a network allow/deny list) enforced on every agent by default; a project can only *tighten* it, never loosen it.
|
||||
- **Import & export** - move tools, workflows, agents, and components between projects as portable JSON bundles (secret *values* never leave).
|
||||
- **Evaluations** - datasets scored by `contains` / `exact` / `regex` / LLM-`judge` for a pass rate per workflow, **streamed live** (each case resolves in the UI as it finishes, with per-case latency + tokens).
|
||||
- **Long-term memory**, response caching, retries with backoff, and per-tenant **budgets**.
|
||||
- **Multi-tenant projects & roles** (owner/admin/editor/viewer/connector) with **per-project RBAC**, scoped revocable API keys, entity **version history**, and an **audit log**.
|
||||
- **Provider-agnostic models** - OpenAI, Anthropic, Google, or any LangChain provider, plus an offline `fake:` model so you can build the plumbing without spending a cent.
|
||||
|
||||
> See the full **[User Manual](docs/MANUAL.md)** for an end-to-end tour and worked examples.
|
||||
|
||||
## Architecture
|
||||
|
||||
A pnpm + Python monorepo with a shared schema contract that keeps the backend and frontend in lockstep:
|
||||
|
||||
```
|
||||
forge/
|
||||
├── apps/
|
||||
│ ├── api/ FastAPI backend - the engine (compiler, registry, middleware), tools,
|
||||
│ │ auth, knowledge, tracing, MCP server, build assistant. Dockerfile +
|
||||
│ │ Alembic migrations live here. [Python]
|
||||
│ └── web/ Next.js console - canvas, config panels, playground, traces. Dockerfile
|
||||
│ lives here. [TS/React]
|
||||
├── packages/
|
||||
│ └── schemas/ Shared JSON Schemas - the single source of truth, imported by the
|
||||
│ backend validator/compiler AND the frontend <SchemaForm>.
|
||||
├── docs/ User manual, roadmap, and media.
|
||||
├── infra/ Production database swaps (Postgres row-level-security policies).
|
||||
└── docker-compose.yml Production-shaped stack (Postgres · Redis · api · worker · web).
|
||||
```
|
||||
|
||||
The **shared schemas** are the contract behind three consumers: the backend **validator** (rejects bad configs on save), the **compiler** (`compile_workflow`, `build_middleware`), and the frontend **`<SchemaForm>`** (forms generated from the same files).
|
||||
|
||||
## Quick start (local, zero-infra)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Python** 3.11–3.13 - the backend engine
|
||||
- **Node** 22 LTS and **pnpm** 9+ - the web console
|
||||
- Nothing else - the local stack runs on SQLite + embedded Chroma + an in-process scheduler, so **no Docker, Postgres, or Redis** is required to start.
|
||||
|
||||
### 1. Configure environment
|
||||
|
||||
```bash
|
||||
cp .env.example .env # macOS/Linux
|
||||
copy .env.example .env # Windows
|
||||
```
|
||||
|
||||
Open `.env` and fill in what you need (e.g. an LLM provider key). Everything is optional to boot; agents that call a model need a provider key. **Never commit your `.env`** - it is already git-ignored.
|
||||
|
||||
### 2. Backend (FastAPI engine)
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
python -m venv .venv && source .venv/bin/activate # .venv\Scripts\activate on Windows
|
||||
pip install -e ".[dev,all]" # engine + tests + vectors/providers/knowledge/MCP
|
||||
pytest # optional: validate the engine (offline)
|
||||
uvicorn forge.main:app --reload --port 8000 # http://localhost:8000/docs
|
||||
```
|
||||
|
||||
### 3. Frontend (Next.js console)
|
||||
|
||||
In a second terminal, from the repo root:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --filter web dev # http://localhost:3000
|
||||
```
|
||||
|
||||
Open **http://localhost:3000** for the console and **http://localhost:8000/docs** for the API. On first run, sign in with `you@forge.local` / `forge-admin`, or create a fresh workspace.
|
||||
|
||||
## Run with Docker (production-shaped)
|
||||
|
||||
The included [`docker-compose.yml`](docker-compose.yml) brings up a production-shaped stack - **Postgres** (app DB + durable checkpointer), **Redis** (shared rate-limit/idempotency + worker queue), the **API**, a **worker**, and the **web** console:
|
||||
|
||||
```bash
|
||||
# Set real secrets first (FORGE_JWT_SECRET, FORGE_BOOTSTRAP_ADMIN_PASSWORD, provider keys)
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
With `FORGE_ENVIRONMENT=production`, Forge enables a hardening guard and **refuses to boot** with default secrets, SQLite, or a non-durable checkpointer. See [`apps/api/README.md`](apps/api/README.md) and **[Manual §13 - Going to production](docs/MANUAL.md)** for the full, annotated configuration.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | What's inside |
|
||||
|---|---|
|
||||
| **[User Manual](docs/MANUAL.md)** | Full feature tour, the node catalog, and end-to-end use cases (no developer knowledge needed). |
|
||||
| **[Backend README](apps/api/README.md)** | API layout, local-vs-production swaps, and dependency notes. |
|
||||
| **[Tech stack & architecture](TECH_STACK.md)** | Every dependency and why it's there, plus request/run sequence diagrams. |
|
||||
| **[Roadmap & status](docs/ROADMAP.md)** | What's shipped, what's in progress, and what's planned next (connectors, more channels, and more). |
|
||||
| **[Changelog](CHANGELOG.md)** | Notable changes, following Keep a Changelog + SemVer. |
|
||||
| **[Contributing](CONTRIBUTING.md)** | Local setup, the checks CI runs, and commit/PR conventions. |
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| **Engine** | LangChain v1 · LangGraph v1 · Deep Agents - MIT framework only |
|
||||
| **Backend** | Python · FastAPI · SQLAlchemy 2 (async) · Pydantic v2 |
|
||||
| **Frontend** | Next.js 14 (App Router) · React 18 · TypeScript · React Flow |
|
||||
| **Data (local)** | SQLite · embedded Chroma · in-process cache/scheduler |
|
||||
| **Data (prod)** | Postgres 16 + pgvector · Redis 7 · Fernet/Vault secrets |
|
||||
| **Observability** | Built-in tracer + cost accounting · OpenTelemetry / Langfuse export |
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Forge is MIT-licensed and built to be extended.
|
||||
|
||||
1. Fork the repo and create a feature branch.
|
||||
2. Backend changes: run `pytest` and `ruff check forge migrations` from `apps/api`.
|
||||
3. Keep the **shared schemas** (`packages/schemas`) authoritative - the validator, compiler, and frontend forms all read from them.
|
||||
4. Open a pull request describing the change and the reasoning.
|
||||
|
||||
Found a bug or have an idea? Please [open an issue](https://github.com/nihalashetty/Forge/issues).
|
||||
|
||||
## License
|
||||
|
||||
Forge is released under the **[MIT License](LICENSE)** - free to use, modify, and distribute, including commercially. It builds only on the MIT-licensed LangChain/LangGraph ecosystem, with no Elastic-2.0 or commercial-license dependencies.
|
||||
|
||||
<div align="center">
|
||||
<sub>Built on the open-source LangChain and LangGraph ecosystem.</sub>
|
||||
</div>
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Security Policy
|
||||
|
||||
Forge is self-hosted and handles sensitive material — provider API keys, encrypted
|
||||
secrets, auth credentials, and outbound requests to your systems. We take security issues
|
||||
seriously.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Forge is pre-1.0 and under active development. Security fixes land on the `main` branch and
|
||||
are included in the next release. Please test against the latest `main` before reporting.
|
||||
|
||||
| Version | Supported |
|
||||
|---|---|
|
||||
| `main` (latest) | ✅ |
|
||||
| older tags | ❌ (please upgrade) |
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please do not open a public issue for security vulnerabilities.**
|
||||
|
||||
Report privately via **[GitHub Security Advisories](https://github.com/nihalashetty/Forge/security/advisories/new)**
|
||||
(Repository → *Security* → *Report a vulnerability*). This keeps the report confidential
|
||||
until a fix is available.
|
||||
|
||||
When reporting, include:
|
||||
|
||||
- A description of the issue and its impact.
|
||||
- Steps to reproduce (a minimal proof-of-concept helps).
|
||||
- Affected component/version (commit SHA if possible).
|
||||
|
||||
**Do not include real secrets, tokens, or customer data** in your report — redact or use
|
||||
placeholders.
|
||||
|
||||
We aim to acknowledge reports promptly, keep you updated on remediation, and credit
|
||||
reporters (unless you prefer to remain anonymous) once a fix ships.
|
||||
|
||||
## Built-in safeguards
|
||||
|
||||
Forge ships several defense-in-depth controls; when reporting, note if your finding
|
||||
bypasses one:
|
||||
|
||||
- **SSRF guard** on every outbound call (tools, webhooks, fetch, crawl) — private,
|
||||
loopback, and cloud-metadata addresses are blocked by default.
|
||||
- **Encrypted, reference-only secrets** (`secret://…`) via a Fernet master key; secret
|
||||
values are write-only and never returned.
|
||||
- **Production hardening guard** that refuses to boot with default secrets, auth disabled,
|
||||
a non-durable checkpointer, or the SSRF guard off.
|
||||
- **Multi-tenant isolation** with query-level scoping and optional Postgres row-level
|
||||
security.
|
||||
- **Sandboxed code tools** (RestrictedPython) and origin-locked embed widgets.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: the Forge API, web console, engine, tools/auth subsystems, and the container
|
||||
stack. Out of scope: vulnerabilities in third-party dependencies (report those upstream;
|
||||
tell us if Forge's usage makes them exploitable), and issues requiring a
|
||||
already-compromised host or admin credentials.
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
# Forge — Technology Stack
|
||||
|
||||
Every technology used in Forge, its purpose, and where it lives. Sourced from `apps/web/package.json`, `apps/api/pyproject.toml`, both `Dockerfile`s, `docker-compose.yml`, and `apps/api/forge/config.py`. Backend deps grouped `[in brackets]` are **optional extras** (installed on demand / prod); everything else is core.
|
||||
|
||||
| Layer | Technology | Use case | Where it lies |
|
||||
|---|---|---|---|
|
||||
| **Frontend** | Next.js 14.2 | React framework; standalone server + build-time API proxy rewrites to the backend | `apps/web` (web console); `next.config.mjs` |
|
||||
| **Frontend** | React 18.3 + React DOM | UI component rendering | `apps/web` |
|
||||
| **Frontend** | TypeScript 5.6 | Typed frontend language | `apps/web` (`tsconfig.json`) |
|
||||
| **Frontend** | @xyflow/react (React Flow) 12 | Visual drag-and-drop node-graph editor — the workflow builder canvas | `apps/web` (builder/canvas components) |
|
||||
| **Frontend** | react-markdown 9 + remark-gfm 4 | Render agent/chat responses as GitHub-flavored Markdown | `apps/web` (chat UI) |
|
||||
| **Frontend** | mustache 4 | Client-side `{{...}}` template rendering | `apps/web` |
|
||||
| **Frontend** | jmespath 0.16 | JSON projection/query in the browser | `apps/web` |
|
||||
| **Frontend / Build** | Node.js 22 | JS runtime for building and serving the console | `apps/web/Dockerfile` (`node:22-alpine`) |
|
||||
| **Build / Monorepo** | pnpm (workspace) | Package manager + monorepo workspaces (`apps/web`, `packages/*`) | repo root (`pnpm-workspace.yaml`, `corepack`) |
|
||||
| **Backend / API** | Python 3.11–3.13 | Backend language (runtime image: `python:3.12-slim`) | `apps/api` |
|
||||
| **Backend / API** | FastAPI 0.115 | HTTP/REST API framework, routing, dependency injection, middleware | `apps/api/forge/main.py`, `forge/routers/*` |
|
||||
| **Backend / API** | Uvicorn[standard] 0.32 | ASGI server that runs the app | serve command (`uvicorn forge.main:app`) |
|
||||
| **Backend / API** | sse-starlette 2.1 | Server-Sent Events streaming of run event frames (`run`/`node_start`/`messages`/`done`) | `forge/routers/runs.py` |
|
||||
| **Backend / API** | python-multipart | Multipart form / file upload parsing | `apps/api` |
|
||||
| **Backend / API** | hatchling | Python package build backend | `apps/api/pyproject.toml` |
|
||||
| **Config / Data** | Pydantic 2.9 | Request/response DTOs, data validation | `forge/schemas/dto.py`, models |
|
||||
| **Config / Data** | pydantic-settings 2.6 | Environment-driven application settings | `forge/config.py` |
|
||||
| **Config / Data** | email-validator 2.2 | Validate email fields (invites, auth) | `apps/api` |
|
||||
| **Agent / Engine** | LangChain 1.3 + langchain-core | LLM orchestration primitives (messages, tools, model bindings) | `forge/engine/*` |
|
||||
| **Agent / Engine** | LangGraph 1.2 | Stateful agent/workflow graph engine — compiles nodes into a runnable graph; the execution core | `forge/engine/compiler.py`, `forge/services/runs.py` |
|
||||
| **Agent / Engine** | langgraph-checkpoint 4 | Checkpointer interface for durable / resumable run + HITL state | `forge/engine`, `forge/services/runs.py` |
|
||||
| **Agent / Engine** | langgraph-checkpoint-sqlite 3 | SQLite-backed checkpointer (dev default) | `.data/checkpoints.sqlite` |
|
||||
| **Agent / Engine** | deepagents 0.6 | Deep Agents harness (planning, subagents, virtual filesystem, sandbox) — always-registered `deep_agent` node | agent node palette |
|
||||
| **Model Providers** | langchain-openai 1.x | OpenAI model access | `[providers]` extra |
|
||||
| **Model Providers** | langchain-anthropic 1.x | Anthropic Claude models + prompt-caching middleware | `[providers]` extra; `default_anthropic_prompt_caching` |
|
||||
| **Model Providers** | langchain-google-genai 4.2+ | Google Gemini models | `[providers]` extra |
|
||||
| **Model Providers** | tiktoken 0.7 | Accurate token counting for the cost meter / budgets (falls back to len/4) | `forge/tracing/pricing.py` |
|
||||
| **Tooling Primitives** | httpx 0.27 | Outbound HTTP for REST/GraphQL tools, webhooks, `web_fetch`, OAuth/token fetches | tool runtime + egress/SSRF guard |
|
||||
| **Tooling Primitives** | jsonschema 4.23 | Validate node/tool config against the shared JSON Schemas | `packages/schemas` + engine |
|
||||
| **Tooling Primitives** | jmespath 1.0 (py) | JSON projection of tool outputs | tool runtime |
|
||||
| **Tooling Primitives** | RestrictedPython 7.4 | AST-sandboxed execution of code tools (opt-in; hardening layer, not OS isolation) | code tool runtime (`enable_code_tools`) |
|
||||
| **MCP** | langchain-mcp-adapters 0.2 | Consume external MCP servers as tools | `[mcp]` extra |
|
||||
| **MCP** | mcp 1.9 | Model Context Protocol SDK | `[mcp]` extra |
|
||||
| **MCP** | fastmcp 3 | Expose Forge projects as MCP servers | `[mcp]` extra |
|
||||
| **Knowledge / RAG** | chromadb 1.5 | Embedded persistent vector store (zero infra) | `[vectors]` extra; `.data/chroma` |
|
||||
| **Knowledge / RAG** | fastembed 0.3+ | **Default embedder** — local open-source ONNX model (no API cost / no PyTorch); also powers the local cross-encoder **reranker** (`TextCrossEncoder`) | `[knowledge]` extra |
|
||||
| **Knowledge / RAG** | langchain-text-splitters 1.x | Chunk/split documents for ingestion | `[knowledge]` extra |
|
||||
| **Knowledge / RAG** | pypdf 5 | Extract text from PDF documents | `[knowledge]` extra |
|
||||
| **Knowledge / RAG** | beautifulsoup4 4.12 | Parse HTML for URL ingestion | `[knowledge]` extra |
|
||||
| **Knowledge / RAG** | rank-bm25 0.2 | Lexical (BM25) ranking for hybrid vector + keyword search | `[knowledge]` extra |
|
||||
| **Persistence** | SQLAlchemy 2.0 [asyncio] | Async ORM / database access layer | `forge/models/entities.py` |
|
||||
| **Persistence** | aiosqlite 0.20 | Async SQLite driver (dev default) | dev DB `.data/forge.db` |
|
||||
| **Persistence** | Alembic 1.14 | Schema migrations (controlled prod path) | `apps/api/migrations`, `alembic.ini` |
|
||||
| **Persistence** | greenlet 3.1 | Async/sync bridge required by SQLAlchemy asyncio | runtime dependency |
|
||||
| **Persistence** | SQLite | Default dev database + checkpointer store | `.data/*.db` (dev only) |
|
||||
| **Persistence** | PostgreSQL 16 | Production application database + shared durable checkpointer | `docker-compose.yml` (prod) |
|
||||
| **Persistence** | asyncpg 0.30 / psycopg[binary,pool] 3.2 | Async Postgres drivers | `[postgres]` extra (prod) |
|
||||
| **Persistence** | langgraph-checkpoint-postgres 2.x | Durable Postgres checkpointer shared across workers (prod/HITL) | `[postgres]` extra (prod) |
|
||||
| **Secrets / Auth** | cryptography 43 (Fernet) | Encrypt stored secrets/credentials with a master key | `.data/master.key`; secrets service |
|
||||
| **Secrets / Auth** | python-jose[cryptography] 3.3 | Mint/verify platform JWT access + refresh tokens (with `kid` rotation) | auth layer (`forge/config.py` JWT settings) |
|
||||
| **Secrets / Auth** | bcrypt 4 | Password hashing for local accounts | auth layer |
|
||||
| **Background / Workers** | Redis 7 | Shared rate-limit / idempotency store + worker queue backend | `docker-compose.yml` (prod); `[workers]` extra |
|
||||
| **Background / Workers** | arq 0.26 | Async task queue + worker for offloaded run execution | `forge/worker.py`, `forge/queue.py`; `[workers]` |
|
||||
| **Background / Workers** | croniter 2–6 | Evaluate cron `schedule` triggers | scheduler; `[workers]` |
|
||||
| **Observability** | opentelemetry-sdk 1.20 | Emit run traces/spans (GenAI semantic conventions) | `forge/tracing/otel.py`; `[observability]` |
|
||||
| **Observability** | opentelemetry-exporter-otlp-proto-http 1.20 | Export spans to an OTLP collector / Langfuse | `forge/tracing/otel.py`; `[observability]` (opt-in via `otel_enabled`) |
|
||||
| **Infra / Deploy** | Docker + Docker Compose | Production-shaped container stack (postgres + redis + api + worker + web) | repo root (`docker-compose.yml`, `apps/*/Dockerfile`) |
|
||||
| **Dev Tooling** | pytest 8.3 + pytest-asyncio 0.24 | Backend test suite (async mode auto) | `apps/api/tests`; `[dev]` extra |
|
||||
| **Dev Tooling** | anyio 4.6 | Async test/runtime utilities | `[dev]` extra |
|
||||
| **Dev Tooling** | ruff 0.15 (pinned `>=0.15,<0.16`) | Linting + import sorting/formatting; range-pinned so CI lint is reproducible | `pyproject.toml [tool.ruff]`; `[dev]` extra |
|
||||
| **Dev Tooling** | mypy 1.13+ | Static type-checking (advisory in CI; gradual adoption) | `pyproject.toml [tool.mypy]`; `[dev]` extra |
|
||||
| **Dev Tooling** | Vitest 2 + Testing Library | Frontend unit/component tests (`pnpm --filter web test`) | `apps/web` (`devDependencies`) |
|
||||
|
||||
**Notes**
|
||||
- **Local dev needs no external infra**: SQLite + embedded Chroma + in-process (fake) cache/queue. The prod swaps — Postgres, Redis, OTLP, Vault/KMS — are configuration-only (no code changes).
|
||||
- **Framework is MIT-only**: LangChain/LangGraph OSS packages; deliberately **not** `langgraph-api` or LangSmith.
|
||||
- **Optional extras** map to `pip install -e ".[...]"` groups in `pyproject.toml`: `providers`, `vectors`, `knowledge`, `mcp`, `workers`, `postgres`, `observability`, `dev` (and `all` = vectors+providers+knowledge+mcp).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
How the pieces above fit together. The browser talks to a **same-origin** Next.js proxy (`/api/forge/*`) that rewrites to the FastAPI backend; every run — whatever triggers it — funnels through one `RunService`, and one `ForgeTracer` observes it.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Client["Client tier"]
|
||||
Browser["Browser — Next.js console<br/>(builder canvas · chat UI)"]
|
||||
Embed["Embed widget<br/>(anonymous · publishable key)"]
|
||||
end
|
||||
|
||||
subgraph Edge["Edge / web server"]
|
||||
Next["Next.js 14 server<br/>same-origin proxy: /api/forge/* to API"]
|
||||
end
|
||||
|
||||
subgraph Triggers["Trigger sources"]
|
||||
UI["Interactive run (SSE)"]
|
||||
WH["Webhook"]
|
||||
SCH["Schedule (cron)"]
|
||||
CH["Email channel"]
|
||||
MCPin["MCP server surface"]
|
||||
end
|
||||
|
||||
subgraph API["FastAPI backend — apps/api/forge"]
|
||||
MW["Middleware<br/>Audit · TrustedHost · CORS · rate-limit<br/>auth: JWT / service token · X-Forge-Context"]
|
||||
Routers["Routers (/v1/...)"]
|
||||
Dispatch["Dispatch service"]
|
||||
RunSvc["RunService<br/>create_run · stream · run_to_completion · resume"]
|
||||
Compile["Engine: build_compile_context to compile_workflow"]
|
||||
Graph["LangGraph StateGraph — graph.astream()"]
|
||||
Tracer["ForgeTracer callback<br/>spans: tokens · cost · latency"]
|
||||
end
|
||||
|
||||
subgraph Nodes["Graph nodes & tools"]
|
||||
Models["Model providers<br/>OpenAI · Anthropic · Google GenAI"]
|
||||
Tools["Tools: httpx REST/GraphQL · MCP client<br/>code (RestrictedPython) · knowledge/RAG"]
|
||||
Deep["deep_agent (Deep Agents)"]
|
||||
end
|
||||
|
||||
subgraph DataTier["Persistence & state"]
|
||||
DB["SQLAlchemy to SQLite (dev) / Postgres (prod)<br/>Run · Trace · Span · Thread · AuditLog"]
|
||||
CP["LangGraph checkpointer<br/>SQLite / Postgres"]
|
||||
Vec["Chroma vector store (.data/chroma)"]
|
||||
Secrets["Fernet master.key (.data)"]
|
||||
end
|
||||
|
||||
subgraph Async["Async / prod tier"]
|
||||
Redis["Redis — rate-limit · idempotency · queue"]
|
||||
Worker["arq worker (offloaded runs)"]
|
||||
OTel["OpenTelemetry to OTLP / Langfuse"]
|
||||
end
|
||||
|
||||
Browser --> Next
|
||||
Embed --> Next
|
||||
Next --> MW
|
||||
UI --> MW
|
||||
WH --> Dispatch
|
||||
SCH --> Dispatch
|
||||
CH --> Dispatch
|
||||
MCPin --> Dispatch
|
||||
MW --> Routers --> RunSvc
|
||||
Dispatch --> RunSvc
|
||||
RunSvc --> Compile --> Graph
|
||||
Graph -. callbacks .-> Tracer
|
||||
Graph --> Models
|
||||
Graph --> Tools
|
||||
Graph --> Deep
|
||||
Tools --> Vec
|
||||
Tools -. encrypt/decrypt .-> Secrets
|
||||
RunSvc --> DB
|
||||
Graph --> CP
|
||||
Tracer --> DB
|
||||
Tracer --> OTel
|
||||
RunSvc <--> Redis
|
||||
Dispatch --> Worker
|
||||
Worker --> RunSvc
|
||||
Routers -. SSE frames .-> Next
|
||||
Next -. SSE .-> Browser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example flow — a user sends a chat message
|
||||
|
||||
The interactive path (console or embed widget). Sending a message is **two HTTP calls**: a `POST` that creates the run row, then a `GET` that opens the SSE stream carrying tokens and lifecycle events back to the browser.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor U as User
|
||||
participant B as Browser (chat UI)
|
||||
participant N as Next.js proxy
|
||||
participant M as FastAPI middleware
|
||||
participant R as Runs router
|
||||
participant S as RunService
|
||||
participant G as LangGraph astream
|
||||
participant T as ForgeTracer
|
||||
participant P as Model provider
|
||||
participant D as DB / checkpointer
|
||||
|
||||
U->>B: type message, hit send
|
||||
B->>N: POST /v1/projects/{p}/workflows/{w}/runs
|
||||
N->>M: same-origin proxy to api:8000
|
||||
M->>R: authenticated request (audit · auth · rate-limit)
|
||||
R->>R: idempotency · run rate limit · identity · daily quota
|
||||
R->>S: create_run(input, thread_id)
|
||||
S->>D: INSERT Run (queued) + Thread
|
||||
S-->>R: run
|
||||
R-->>B: 201 { run_id, thread_id }
|
||||
|
||||
B->>N: GET /v1/.../runs/{run_id}/stream (EventSource / SSE)
|
||||
N->>R: proxy stream request
|
||||
R->>S: stream(run_id)
|
||||
S->>D: Run to running
|
||||
S->>G: build context to compile_workflow, astream(callbacks=[Tracer])
|
||||
activate G
|
||||
loop each node / token
|
||||
G->>T: on_llm_start / on_tool_start (open span)
|
||||
G->>P: LLM call
|
||||
P-->>G: tokens
|
||||
G-->>B: SSE: node_start · messages (tokens) · custom
|
||||
G->>T: on_llm_end (tokens · cost · latency)
|
||||
end
|
||||
deactivate G
|
||||
alt HITL interrupt
|
||||
S-->>B: SSE: interrupt (awaiting human input)
|
||||
Note over B,S: user approves to resume the same run
|
||||
else completed
|
||||
S->>D: _write_trace to Trace + Span rows (+ OTel export)
|
||||
S-->>B: SSE: done { answer, total_tokens, total_cost_usd }
|
||||
end
|
||||
B->>U: render streamed answer
|
||||
```
|
||||
|
||||
> **Non-interactive triggers** (webhook / schedule / email) skip the browser and the SSE stream: they enter through the **Dispatch service** and call `RunService.run_to_completion()` instead of `stream()` — but the compile → LangGraph → ForgeTracer → Trace/Span path is identical, which is what keeps observability consistent across every entry point.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Forge API - FastAPI on the MIT LangChain/LangGraph stack.
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
# The default local (fastembed) embedder's model is baked into the image at this path
|
||||
# (see the pre-download step below); the app reads the same dir at runtime, so ingestion
|
||||
# works fully offline with no first-run download. Outside the /app/.data volume, so it's
|
||||
# part of the image, not the persisted volume.
|
||||
FORGE_FASTEMBED_CACHE_DIR=/app/.fastembed-cache
|
||||
|
||||
# Build deps for psycopg/cryptography wheels (kept minimal; most ship manylinux wheels).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Build context is the REPO ROOT (see docker-compose.yml: context: . / dockerfile:
|
||||
# apps/api/Dockerfile) so we can COPY packages/schemas, which lives above apps/api.
|
||||
#
|
||||
# Third-party deps install in a layer keyed only on pyproject.toml + a package stub, so a
|
||||
# pure forge/ source edit does NOT invalidate this (expensive) layer. The editable install
|
||||
# just links /app/forge onto sys.path, so overwriting the stub with the real tree afterwards
|
||||
# needs no reinstall. The BuildKit pip cache mount reuses already-downloaded wheels instead
|
||||
# of re-hitting PyPI even when the layer does re-run (e.g. a pyproject.toml change), so a
|
||||
# source edit can never turn into a PyPI round-trip / offline build failure.
|
||||
COPY apps/api/pyproject.toml apps/api/README.md ./
|
||||
RUN mkdir -p forge && touch forge/__init__.py
|
||||
# Production extras: model providers, vectors, knowledge, MCP, workers (Redis/arq), Postgres.
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --upgrade pip && \
|
||||
pip install -e ".[providers,vectors,knowledge,mcp,workers,postgres]"
|
||||
|
||||
# Real source over the stub (this layer changes on every edit, but it is AFTER the install).
|
||||
COPY apps/api/forge ./forge
|
||||
COPY apps/api/alembic.ini ./alembic.ini
|
||||
COPY apps/api/migrations ./migrations
|
||||
# Shared JSON Schemas live at the repo root, outside the old ./apps/api context. Bake them at
|
||||
# /app/packages/schemas so config.py's schemas_dir default (/app/packages/schemas) resolves.
|
||||
COPY packages/schemas ./packages/schemas
|
||||
|
||||
# Pre-download the default local embedder (fastembed) model into the image so the container
|
||||
# ships self-contained: no ~130MB HuggingFace fetch on the first ingest, and no runtime egress
|
||||
# to huggingface.co (which a locked-down deploy may block). Keep this model id in sync with
|
||||
# forge.knowledge.embeddings._DEFAULT_FASTEMBED. Runs before the chown so the forge user owns it.
|
||||
RUN python -c "import os; from fastembed import TextEmbedding; TextEmbedding(model_name='BAAI/bge-small-en-v1.5', cache_dir=os.environ['FORGE_FASTEMBED_CACHE_DIR'])"
|
||||
|
||||
# Run as a non-root user. Pre-create /app/.data (holds the Fernet master.key + Chroma store)
|
||||
# owned by forge: an empty named volume mounted there copies the image mountpoint's ownership on
|
||||
# first use, so this keeps the volume forge-writable. Without it the volume would mount root-owned
|
||||
# and the non-root process could not write the master key (PermissionError at startup).
|
||||
RUN useradd --create-home --uid 10001 forge \
|
||||
&& mkdir -p /app/.data \
|
||||
&& chown -R forge:forge /app
|
||||
USER forge
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Container healthcheck hits the readiness probe (DB + checkpointer).
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD curl -fsS http://localhost:8000/readyz || exit 1
|
||||
|
||||
# Apply migrations, then serve. (Schema also self-bootstraps via create_all, but on managed
|
||||
# Postgres `alembic upgrade head` is the controlled path.)
|
||||
# No uvicorn --proxy-headers/--forwarded-allow-ips: the app derives the client IP itself and
|
||||
# trusts X-Forwarded-For only from FORGE_TRUSTED_PROXIES (default none -> the real socket peer).
|
||||
# Letting uvicorn rewrite the peer from XFF for ANY client (as --forwarded-allow-ips='*' did)
|
||||
# lets clients spoof their IP to evade per-IP rate limits / poison audit logs. Behind a real
|
||||
# proxy, set FORGE_TRUSTED_PROXIES to the proxy IP(s).
|
||||
CMD ["sh", "-c", "alembic upgrade head && uvicorn forge.main:app --host 0.0.0.0 --port 8000"]
|
||||
@@ -0,0 +1,61 @@
|
||||
# Forge API
|
||||
|
||||
FastAPI backend for **Forge** - the self-hosted agent platform. Built directly on the
|
||||
MIT-licensed LangChain v1 + LangGraph v1 framework. **Never** depends on `langgraph-api`
|
||||
(Elastic 2.0) or LangSmith (commercial).
|
||||
|
||||
## Local dev (zero external infra)
|
||||
|
||||
The default local stack needs **no Docker, Postgres, or Redis**:
|
||||
|
||||
| Concern | Local default | Production swap (config-only) |
|
||||
|---|---|---|
|
||||
| Relational DB | SQLite (`aiosqlite`) | Postgres 16 (`asyncpg` / `psycopg`) |
|
||||
| Run durability | `langgraph-checkpoint-sqlite` | `langgraph-checkpoint-postgres` |
|
||||
| Vectors | Chroma (embedded, persistent) | `pgvector` in the same Postgres - set `vector_backend=pgvector` |
|
||||
| Cache / queue | in-process | Redis 7 + arq |
|
||||
| Secrets | Fernet (local key file) | Vault / cloud KMS |
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate # Windows (source .venv/bin/activate on *nix)
|
||||
pip install -e ".[dev]" # core + test deps only
|
||||
pip install -e ".[dev,all]" # full local stack (vectors + providers + knowledge + MCP)
|
||||
|
||||
cp ../../.env.example .env
|
||||
uvicorn forge.main:app --reload --port 8000
|
||||
pytest # validate the engine
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
apps/api/
|
||||
forge/
|
||||
main.py FastAPI app factory + lifespan (DB init, checkpointer, scheduler/reaper)
|
||||
config.py Settings (pydantic-settings, env-driven) + production hardening guard
|
||||
deps.py FastAPI dependencies: session, auth/tenant resolution, RBAC
|
||||
security.py Auth primitives: bcrypt password hashing + JWT mint/verify
|
||||
audit_middleware.py ASGI middleware that audits successful mutations
|
||||
queue.py, worker.py Optional arq/Redis queue + worker for offloaded runs (prod)
|
||||
db/ async engine, session, tenant scoping, dev seed/bootstrap
|
||||
models/ SQLAlchemy ORM (tenants, projects, workflows, runs, traces, ...)
|
||||
schemas/ Pydantic request/response DTOs + shared JSON-Schema loader/validator
|
||||
services/ business logic (ProjectSvc, WorkflowSvc, RunSvc, assistant,
|
||||
portability import/export, tool_sets, ...)
|
||||
routers/ HTTP + SSE endpoints (incl. assistant, runs, mcp_server, mcp_oauth,
|
||||
mcp_tokens, tool_sets, connections, models, versions, embed)
|
||||
engine/ the heart: registry, compiler, state, middleware_compiler, context
|
||||
nodes/ node-type factories (start, end, agent, llm, tool_call, flow, rag, triggers)
|
||||
tools/ tool materialization (rest, graphql, code, sql, mcp, builtin) + projection
|
||||
auth_providers/ Auth Provider resolver (csrf_session, oauth2, bearer, ...)
|
||||
secrets/ Fernet-encrypted, reference-only secret store
|
||||
channels/ email deployment surface
|
||||
knowledge/ EmbeddingStore (Chroma), ingestion/crawl, splitter, hybrid + rerank
|
||||
tracing/ ForgeTracer callback + span sink + pricing + tool-I/O capture
|
||||
util/ cross-cutting helpers (SSRF guard, http client, rate limit, mailer, ...)
|
||||
assistant_skills/ skill(s) the in-product build assistant loads
|
||||
migrations/ Alembic migrations (prod schema path; SQLite auto-creates in dev)
|
||||
tests/ pytest suite (engine, tools, knowledge, auth, security, ...)
|
||||
```
|
||||
@@ -0,0 +1,40 @@
|
||||
# Alembic config for Forge. The DB URL comes from FORGE_DATABASE_URL via env.py
|
||||
# (this file's sqlalchemy.url is a placeholder and is overridden there).
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = sqlite:///./.data/forge.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Forge API - self-hosted LangChain/LangGraph agent platform."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
name: forge-platform
|
||||
description: Use when designing, building, debugging, or explaining Forge workflows, nodes, state, routing, middleware, tools, or knowledge - especially custom/complex shapes beyond the canned builders, AND for anything about deep agents, sub-agents, or supervisor patterns (how sub-agents are wired on the canvas), or per-environment tool endpoints ({{env.*}}).
|
||||
---
|
||||
|
||||
# Forge platform deep guide
|
||||
|
||||
You are embedded in Forge, a visual builder for LangChain/LangGraph agents. Workflows are
|
||||
JSON definitions compiled to LangGraph StateGraphs. This guide covers the rules the canned
|
||||
builder tools don't teach. For the live catalog always call `list_node_types`,
|
||||
`get_node_schema(type)`, and `list_middleware_types` - they read the real registry.
|
||||
|
||||
## Workflow definition shape
|
||||
|
||||
```json
|
||||
{
|
||||
"state": {"messages": {"type": "list[message]", "reducer": "add_messages"},
|
||||
"intent": {"type": "str", "reducer": "last"}},
|
||||
"entry_node": "start",
|
||||
"nodes": [{"id": "start", "type": "start", "config": {}, "position": {"x": 40, "y": 200}}],
|
||||
"edges": [{"source": "start", "target": "..."}]
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Exactly one `start` node; at least one `end` node; every node reachable; a path must reach end.
|
||||
- Every state key a node WRITES must be declared in `state` (LangGraph rejects undeclared
|
||||
writes). `create_custom_workflow` auto-declares keys for known node configs, but declare
|
||||
custom `output_key`s yourself. Types: str, int, float, bool, json, list[str], list[json],
|
||||
list[message]. Reducers: last (overwrite), add (append lists), merge (dict merge),
|
||||
add_messages (chat history).
|
||||
- Messages flow on the `messages` channel; nodes append, never replace.
|
||||
|
||||
## Routing patterns
|
||||
|
||||
- Triage first (almost every support/chatbot graph): right after `start`, classify the
|
||||
message into at least `general` vs `support`, then `router` it. Send `general` (greetings,
|
||||
smalltalk, "what can you do?", capability/meta questions) to a small friendly agent that
|
||||
answers directly and goes to `end`; route only `support` into the retrieval/ticket
|
||||
pipeline. Without this, greetings and meta questions fall through retrieval, miss,
|
||||
and dead-end at a "no relevant data → create a ticket" path - a bad first impression.
|
||||
Shape: start → classify(general|support) → router → {general: greeter_agent → end,
|
||||
support: retrieval → … → end}. (Simpler alt: one front agent with a knowledge_search tool
|
||||
that both chats and answers.)
|
||||
- Single intent: `classifier` (labels, output_key=intent) → `router`
|
||||
(expression=intent, cases {label: node_id}, default=fallback_node). Case KEYS are the
|
||||
exact VALUES the expression takes, not display labels.
|
||||
- ALWAYS give routers a `default` - with no default, an unmatched value silently ends the
|
||||
run with no answer.
|
||||
- MULTI-INTENT (a question with several asks): classifier `multi_label: true` writes a
|
||||
LIST to state (declare it `list[str]`); router `multi: true` routes to EVERY matching
|
||||
case in parallel. All branches then converge on ONE synthesizer agent node before end -
|
||||
its prompt: "compose the partial answers above into one coherent reply". Without a
|
||||
synthesizer the user sees only the last branch's answer.
|
||||
- Simpler multi-intent alternative (preferred for support bots): ONE agent with
|
||||
`config.knowledge` enabled (rag and/or qa) plus any REST tools. The agent searches the KB
|
||||
once per sub-question and composes one answer itself. Fewer nodes, no fan-out needed.
|
||||
- Conditional on retrieval success: retrieval `route_key` writes "yes"/"no";
|
||||
human decisions: human_input `output_key` writes the decision string.
|
||||
|
||||
## Knowledge
|
||||
|
||||
- Sources (documents) live in folders (free-form names; "" = unfiled). retrieval node
|
||||
`folders: ["Manuals"]` and the `knowledge_search` tool's `folder` arg filter by folder.
|
||||
- Q&A pairs have a free-form `kind` (faq, error_workaround, or custom kinds the user
|
||||
creates) + tags. The retrieval node (include_qa) and agent Q&A filter by `kinds`; empty = all.
|
||||
- Three ways to ground an agent, pick by how much control you need:
|
||||
1. `retrieval` NODE = fixed pre-step grounding (one search over BOTH docs + Q&A per run,
|
||||
before the agent; structurally guaranteed). Use when grounding MUST happen.
|
||||
2. Agent `config.knowledge` (PREFERRED for conversational/multi-part agents) = built-in,
|
||||
agent-driven KB access, no separate Tool needed:
|
||||
```json
|
||||
"knowledge": {
|
||||
"rag": {"enabled": true, "folders": ["Manuals"], "top_k": 4},
|
||||
"qa": {"enabled": true, "kinds": ["faq"]}
|
||||
}
|
||||
```
|
||||
Compiles to `search_knowledge_base` (documents) and/or `lookup_faq` (curated Q&A),
|
||||
each toggled and scoped (folders / kinds) independently. The agent searches per
|
||||
sub-question in its own phrasing - so ONE agent answers multi-part questions.
|
||||
3. `knowledge_search` builtin TOOL = same idea but as a standalone Tool row (use when you
|
||||
want to share one tool across agents, or filter folder per-call). For a single agent,
|
||||
`config.knowledge` is simpler.
|
||||
|
||||
## Human-in-the-loop (real interrupts only)
|
||||
|
||||
- `human_input` node pauses the run (LangGraph interrupt) until a human decides in the
|
||||
Playground. `output_key` exposes the decision to a router.
|
||||
- HumanInTheLoopMiddleware (`approve_tools` on builders / `human_in_the_loop` middleware)
|
||||
pauses before specific TOOL calls.
|
||||
- NEVER simulate approval via prompt text. Verify with test_workflow that nodes_visited
|
||||
ends in `__interrupt__`.
|
||||
|
||||
## Middleware (agent nodes)
|
||||
|
||||
Per-agent `middleware: [{type, config, enabled}]`. Useful types: summarization,
|
||||
model_fallback, model_retry, tool_retry (retry_on: timeout/connection/http_error/...),
|
||||
pii, guardrail_regex (block actually replaces the reply), model_call_limit,
|
||||
tool_call_limit, tenant_budget, llm_tool_selector, context_editing, tool_emulator,
|
||||
dynamic_model_by_state, tool_filter_by_context, human_in_the_loop. Call
|
||||
`list_middleware_types` for configs.
|
||||
|
||||
## Deep agents & sub-agents (supervisor pattern)
|
||||
|
||||
- A `deep_agent` node is a supervisor: `create_agent` + only the deepagents middleware its config
|
||||
toggles on. `planning: true` adds a write_todos planner; `filesystem.enabled` adds a virtual FS;
|
||||
`skills` adds skill files. All are OFF by default (each is pure token overhead until needed) - a
|
||||
plain `agent` is cheaper for a simple lookup; reach for `deep_agent` for open-ended, multi-step
|
||||
work you want to DELEGATE.
|
||||
- **Sub-agents are wired on the canvas**, not inline JSON: an edge with `source_handle: "subagents"`
|
||||
from the deep_agent to a specialist `agent` (or `deep_agent`) node folds that node in as a callable
|
||||
sub-agent. The compiler lifts each wired node's config into the supervisor's `subagents`
|
||||
(name/description/system_prompt/tools/model) and drops it as a standalone graph node. The
|
||||
supervisor calls them via the `task` tool; results come back as named `subagent · <name>` spans.
|
||||
- Give every specialist a clear **`config.description`** - the supervisor reads it (like a tool
|
||||
description) to decide when to dispatch it. Each sub-agent keeps its OWN model + tools; if unset it
|
||||
inherits the supervisor's model.
|
||||
- Folded sub-agent nodes legitimately have NO outgoing flow edge (they run inside the supervisor, not
|
||||
as graph steps) - validation exempts them from the dead-end warning. The supervisor still needs its
|
||||
own `→ end` edge. Sub-agents can't see the chat history, so the supervisor must pass any needed
|
||||
identifiers in the `task` description.
|
||||
- Prefer a canvas supervisor over one mega-prompt when the work splits into distinct specialties
|
||||
(e.g. lookup / create / update / validate); it keeps each sub-agent's tools + prompt focused.
|
||||
|
||||
## Per-environment tool endpoints
|
||||
|
||||
- A REST `url_template` / GraphQL `endpoint` (or an auth template) may reference `{{env.<key>}}`,
|
||||
resolved from the deployment's `FORGE_TOOL_VARS` map - so one tool row targets dev/qa/prod hosts
|
||||
per deploy. A referenced key that isn't configured FAILS the call loudly (never a broken URL).
|
||||
`{{ctx.*}}` (per-run injected values) stays lenient. Use `{{env.*}}` for the base host, `{{ctx.*}}`
|
||||
for per-user/per-run values (tokens, ids).
|
||||
|
||||
## Build discipline
|
||||
|
||||
1. write_todos the plan. 2. list_resources (reuse, never duplicate names).
|
||||
3. Build (canned builder if it fits, else create_custom_workflow). 4. test_workflow with
|
||||
a realistic question, a greeting, an off-topic question - and every branch/intent.
|
||||
5. evaluate_build to judge the results against the user's actual request. 6. Fix and
|
||||
re-test until the judge passes. Only then report success.
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Centralized audit middleware.
|
||||
|
||||
Records every successful mutating request (POST/PUT/PATCH/DELETE) as an AuditLog row -
|
||||
so create/update/delete of any resource is audited without each router opting in. Pure
|
||||
ASGI (peeks at the response-start status only) so it never buffers a body and can't break
|
||||
the SSE run/assistant streams. The actor is taken from the JWT when present, else the
|
||||
seeded dev tenant; auth endpoints are skipped (already audited in their router).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.config import settings
|
||||
from forge.security import TokenError, decode_token
|
||||
from forge.services.audit import AuditService
|
||||
from forge.util.clientip import resolve_client_ip
|
||||
|
||||
_SKIP_PREFIXES = ("/v1/auth", "/v1/audit")
|
||||
_MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
def _actor_from_headers(headers: dict[bytes, bytes]) -> tuple[str | None, str | None]:
|
||||
auth = headers.get(b"authorization", b"").decode("latin-1")
|
||||
if auth[:7].lower() == "bearer ":
|
||||
try:
|
||||
claims = decode_token(auth[7:].strip(), expected_type="access")
|
||||
return claims.get("sub"), claims.get("tid")
|
||||
except TokenError:
|
||||
return None, None
|
||||
return None, None
|
||||
|
||||
|
||||
def _client_ip(scope, headers: dict[bytes, bytes]) -> str | None:
|
||||
# Believe X-Forwarded-For only from a configured reverse proxy (settings.trusted_proxies) -
|
||||
# the SAME rule as deps.client_ip. Previously this trusted XFF unconditionally, so any
|
||||
# client could poison the audit IP.
|
||||
client = scope.get("client")
|
||||
peer = client[0] if client else None
|
||||
fwd = headers.get(b"x-forwarded-for")
|
||||
return resolve_client_ip(peer, fwd.decode("latin-1") if fwd else None, settings.trusted_proxies)
|
||||
|
||||
|
||||
class AuditMiddleware:
|
||||
def __init__(self, app) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http" or scope.get("method") not in _MUTATING:
|
||||
return await self.app(scope, receive, send)
|
||||
path = scope.get("path", "")
|
||||
if any(path.startswith(p) for p in _SKIP_PREFIXES):
|
||||
return await self.app(scope, receive, send)
|
||||
|
||||
status_code = {"v": 0}
|
||||
|
||||
async def send_wrapper(message):
|
||||
if message["type"] == "http.response.start":
|
||||
status_code["v"] = message["status"]
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_wrapper)
|
||||
|
||||
if not (200 <= status_code["v"] < 400):
|
||||
return
|
||||
headers = dict(scope.get("headers") or [])
|
||||
actor_id, tenant_id = _actor_from_headers(headers)
|
||||
if tenant_id is None:
|
||||
app = scope.get("app")
|
||||
tenant_id = getattr(getattr(app, "state", None), "tenant_id", None)
|
||||
if not tenant_id:
|
||||
return
|
||||
# Record the matched route TEMPLATE (e.g. "/v1/projects/{project_id}/tools/{tool_id}/test")
|
||||
# rather than the concrete path. The concrete path carries UUIDs that overflow the action
|
||||
# column (String(80)) and make actions un-aggregatable. FastAPI sets scope["route"] during
|
||||
# routing, which ran inside self.app (already returned); fall back to the concrete path when
|
||||
# nothing matched (404) or a non-APIRoute (bare Mount) handled it. The concrete path is kept
|
||||
# in meta so the specific resource is still recoverable for forensics.
|
||||
route_template = getattr(scope.get("route"), "path", None) or path
|
||||
await AuditService.log(
|
||||
tenant_id=tenant_id, action=f"{scope['method']} {route_template}", actor_id=actor_id,
|
||||
ip=_client_ip(scope, headers), status="ok",
|
||||
meta={"status_code": status_code["v"], "path": path},
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Auth Providers: fetch/extract/inject credentials for downstream tool calls."""
|
||||
|
||||
from forge.auth_providers.resolver import AuthResolver, ResolvedAuth
|
||||
|
||||
__all__ = ["AuthResolver", "ResolvedAuth"]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Extract values from a token-fetch response (header / cookie / json path)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def _json_path(data: Any, path: str) -> Any:
|
||||
cur = data
|
||||
for part in path.split("."):
|
||||
if isinstance(cur, dict):
|
||||
cur = cur.get(part)
|
||||
elif isinstance(cur, list) and part.isdigit():
|
||||
i = int(part)
|
||||
cur = cur[i] if 0 <= i < len(cur) else None
|
||||
else:
|
||||
return None
|
||||
return cur
|
||||
|
||||
|
||||
def extract_value(resp: httpx.Response, rule: dict) -> Any:
|
||||
src = rule.get("from")
|
||||
if src == "header":
|
||||
return resp.headers.get(rule["header"])
|
||||
if src == "cookie":
|
||||
return resp.cookies.get(rule["cookie"])
|
||||
if src == "json":
|
||||
try:
|
||||
return _json_path(resp.json(), rule["json_path"])
|
||||
except Exception: # noqa: BLE001 - non-JSON body
|
||||
return None
|
||||
return None
|
||||
@@ -0,0 +1,316 @@
|
||||
"""AuthResolver - resolve an Auth Provider to headers/cookies/params for a tool call.
|
||||
|
||||
Caches per (provider, per-user-context-hash) with TTL (in-process here; Redis in
|
||||
prod). Invalidates on 401/403 (handled by the calling tool). Per-user secrets the
|
||||
widget injects arrive in `context` and are never stored (Doc 2 §11).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from forge.auth_providers.extract import extract_value
|
||||
from forge.auth_providers.templates import render_value
|
||||
from forge.config import settings
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import AuthProvider
|
||||
from forge.secrets.store import SecretStore
|
||||
from forge.util.http import shared_async_client
|
||||
from forge.util.locks import KeyedLocks
|
||||
from forge.util.ssrf import guarded_request
|
||||
|
||||
# Serialize a provider's (per-user) OAuth refresh so concurrent resolves don't each POST the
|
||||
# one-time refresh_token and clobber the rotated bundle - the loser would then hold a token the
|
||||
# IdP has already invalidated (finding i). In-process (single worker); a distributed lock is
|
||||
# needed for multi-worker, same as the rest of util.locks.
|
||||
_oauth_refresh_locks = KeyedLocks()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedAuth:
|
||||
headers: dict[str, str] = field(default_factory=dict)
|
||||
cookies: dict[str, str] = field(default_factory=dict)
|
||||
params: dict[str, str] = field(default_factory=dict)
|
||||
expires_at: float | None = None # monotonic seconds
|
||||
|
||||
@property
|
||||
def expired(self) -> bool:
|
||||
return self.expires_at is not None and time.monotonic() >= self.expires_at
|
||||
|
||||
|
||||
class AuthResolver:
|
||||
def __init__(self, secrets: SecretStore | None = None, session_factory=SessionLocal) -> None:
|
||||
self.secrets = secrets or SecretStore(session_factory)
|
||||
self._sf = session_factory
|
||||
self._cache: dict[str, ResolvedAuth] = {}
|
||||
|
||||
async def _load(self, tenant_id: str, provider_id: str) -> AuthProvider | None:
|
||||
async with self._sf() as session:
|
||||
return (
|
||||
await session.execute(
|
||||
select(AuthProvider).where(
|
||||
AuthProvider.tenant_id == tenant_id, AuthProvider.id == provider_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
def _key(provider_id: str, context: dict, per_user_keys: list[str]) -> str:
|
||||
dims = "|".join(f"{k}={context.get(k)}" for k in sorted(per_user_keys or []))
|
||||
return provider_id + "::" + hashlib.sha256(dims.encode()).hexdigest()[:16]
|
||||
|
||||
async def invalidate(self, key: str) -> None:
|
||||
self._cache.pop(key, None)
|
||||
|
||||
async def resolve(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
project_id: str,
|
||||
provider_id: str,
|
||||
context: dict | None = None,
|
||||
force: bool = False,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
provider: AuthProvider | None = None,
|
||||
) -> ResolvedAuth:
|
||||
context = context or {}
|
||||
provider = provider or await self._load(tenant_id, provider_id)
|
||||
if provider is None:
|
||||
raise KeyError(f"Auth provider {provider_id!r} not found")
|
||||
cfg = provider.config or {}
|
||||
per_user = cfg.get("per_user_context_keys", [])
|
||||
# A per-user provider may also accept an INLINE token from the run context (token_ctx_key,
|
||||
# for server-to-server /run forwarding). That value must vary the cache key too, or a cached
|
||||
# ResolvedAuth for one caller's inline token could be served to another sharing the same
|
||||
# end_user dims. The stored-connection path is unaffected (the key is absent from context).
|
||||
effective_ctx_key = cfg.get("token_ctx_key") or settings.default_token_ctx_key
|
||||
cache_dims = [*per_user, effective_ctx_key] if effective_ctx_key else per_user
|
||||
key = self._key(provider_id, context, cache_dims)
|
||||
if not force and (cached := self._cache.get(key)) and not cached.expired:
|
||||
return cached
|
||||
|
||||
async def read(ref: str | None) -> Any:
|
||||
if not ref:
|
||||
return None
|
||||
return await self.secrets.read_ref(tenant_id=tenant_id, project_id=project_id, ref=ref)
|
||||
|
||||
# `credentials_ref` is the primary secret for csrf_session/custom_script, but only a
|
||||
# *fallback* for bearer/api_key (and unused for basic/oauth2). A stale or missing
|
||||
# fallback must not abort a provider whose own ref (token_ref/value_ref/…) resolves -
|
||||
# the per-kind branches below still raise clearly if their primary ref is absent.
|
||||
try:
|
||||
creds = await read(provider.credentials_ref or cfg.get("credentials_ref"))
|
||||
except Exception: # noqa: BLE001 - absent fallback secret is tolerated
|
||||
creds = None
|
||||
kind = provider.kind
|
||||
resolved = ResolvedAuth()
|
||||
default_ttl = cfg.get("cache_ttl_seconds", 1800)
|
||||
|
||||
if kind == "bearer":
|
||||
token = await self._value_for(provider, cfg, read, context, shared_ref=cfg.get("token_ref"), fallback=creds)
|
||||
resolved.headers[cfg.get("header_name", "Authorization")] = (
|
||||
cfg.get("prefix", "Bearer ") + str(token)
|
||||
)
|
||||
resolved.expires_at = None if default_ttl == 0 else time.monotonic() + default_ttl
|
||||
elif kind == "api_key":
|
||||
value = await self._value_for(provider, cfg, read, context, shared_ref=cfg.get("value_ref"), fallback=creds)
|
||||
where, name = cfg.get("in", "header"), cfg["name"]
|
||||
(resolved.headers if where == "header" else resolved.params)[name] = str(value)
|
||||
resolved.expires_at = None
|
||||
elif kind == "basic":
|
||||
user = await read(cfg.get("username_ref"))
|
||||
pw = await read(cfg.get("password_ref"))
|
||||
token = base64.b64encode(f"{user}:{pw}".encode()).decode()
|
||||
resolved.headers["Authorization"] = "Basic " + token
|
||||
resolved.expires_at = None
|
||||
elif kind == "oauth2_client_credentials":
|
||||
resolved = await self._oauth2(cfg, read, client)
|
||||
elif kind == "oauth2_authorization_code":
|
||||
resolved = await self._oauth2_auth_code(provider, cfg, read, tenant_id, project_id, client, context)
|
||||
elif kind == "csrf_session":
|
||||
resolved = await self._csrf_session(cfg, {"cred": creds, "ctx": context}, client, default_ttl)
|
||||
elif kind == "custom_script": # pragma: no cover - advanced/audited
|
||||
raise NotImplementedError("custom_script auth requires the advanced-scripts feature flag.")
|
||||
else:
|
||||
raise ValueError(f"Unknown auth kind {kind!r}")
|
||||
|
||||
# Extra fixed headers stamped on every call (in addition to the primary auth header) - e.g. a
|
||||
# constant client id + a service token defined ONCE on the provider instead of hardcoded per
|
||||
# tool. Each value is a literal or a secret:// ref (resolved from the secret store), so a
|
||||
# secret never has to live in plaintext in a tool's header config.
|
||||
for hname, hval in (cfg.get("extra_headers") or {}).items():
|
||||
resolved_val = await read(hval) if isinstance(hval, str) and hval.startswith("secret://") else hval
|
||||
if resolved_val is not None:
|
||||
resolved.headers[hname] = str(resolved_val)
|
||||
|
||||
self._cache[key] = resolved
|
||||
return resolved
|
||||
|
||||
async def _value_for(self, provider, cfg: dict, read, context: dict, *, shared_ref, fallback):
|
||||
"""The token/value for a bearer/api_key provider.
|
||||
|
||||
When the provider is PER-USER (config.per_user_context_keys set, e.g. ["end_user_id"]) the
|
||||
value is the acting user's OWN connected credential - read from the per-user bundle each user
|
||||
deposits self-service (set_user_connection), keyed by the same per_user dims. So every end
|
||||
user supplies their own token and a tool acts as them downstream, with NO shared secret and
|
||||
NO passthrough of the inbound (MCP/session) token. A user who hasn't connected yet resolves
|
||||
to a clear "not connected" error rather than a silent miss.
|
||||
|
||||
Otherwise it's the shared secret ref (or the credentials_ref fallback) - the prior behavior,
|
||||
preserved exactly for non-per-user providers."""
|
||||
per_user = cfg.get("per_user_context_keys")
|
||||
if not per_user:
|
||||
return await read(shared_ref) or fallback
|
||||
# Inline per-request token (server-to-server /run forwarding via X-Forge-Context) takes
|
||||
# precedence over the stored connection, so ONE per-user provider serves both delivery paths:
|
||||
# a chat backend forwards the user's token inline, OR the user connected it once (MCP/console).
|
||||
# The provider's own token_ctx_key wins; else fall back to the deployment-wide default
|
||||
# (settings.default_token_ctx_key) so an integration that always forwards the same key works
|
||||
# for every per-user provider WITHOUT per-provider config (survives project re-creation).
|
||||
ctx_key = cfg.get("token_ctx_key") or settings.default_token_ctx_key
|
||||
if ctx_key and (context or {}).get(ctx_key):
|
||||
return context[ctx_key]
|
||||
name = self.bundle_secret_name(provider.id, context, per_user)
|
||||
try:
|
||||
bundle = await read(f"secret://proj/{name}")
|
||||
except Exception: # noqa: BLE001 - "not connected" surfaces as a missing secret
|
||||
bundle = None
|
||||
if not isinstance(bundle, dict) or not bundle.get("access_token"):
|
||||
raise KeyError(
|
||||
f"Auth provider {provider.id!r} is per-user and the acting user has not connected "
|
||||
f"their credential yet (and no inline token was forwarded)"
|
||||
)
|
||||
return bundle["access_token"]
|
||||
|
||||
async def _oauth2(self, cfg: dict, read, client: httpx.AsyncClient | None) -> ResolvedAuth:
|
||||
data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": await read(cfg.get("client_id_ref")),
|
||||
"client_secret": await read(cfg.get("client_secret_ref")),
|
||||
}
|
||||
if cfg.get("scope"):
|
||||
data["scope"] = cfg["scope"]
|
||||
if cfg.get("audience"):
|
||||
data["audience"] = cfg["audience"]
|
||||
client = client or shared_async_client()
|
||||
# SSRF-guarded (host validated pre-connect + every redirect hop) so a tenant-configured
|
||||
# token_url can't be aimed at an internal/metadata endpoint while carrying secrets (S8).
|
||||
r = await guarded_request(client, "POST", cfg["token_url"], data=data, timeout=30, follow_redirects=True)
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
token = body.get("access_token", "")
|
||||
ttl = body.get("expires_in", cfg.get("cache_ttl_seconds", 3600))
|
||||
return ResolvedAuth(headers={"Authorization": f"Bearer {token}"}, expires_at=time.monotonic() + ttl)
|
||||
|
||||
@staticmethod
|
||||
def _per_user_suffix(context: dict | None, per_user_keys: list[str] | None) -> str:
|
||||
"""A stable short hash of the per-user context dims, so each end-user's OAuth bundle is
|
||||
stored under its own secret name when the provider is configured per-user (finding i)."""
|
||||
if not per_user_keys:
|
||||
return ""
|
||||
dims = "|".join(f"{k}={(context or {}).get(k)}" for k in sorted(per_user_keys))
|
||||
return "__u" + hashlib.sha256(dims.encode()).hexdigest()[:12]
|
||||
|
||||
@staticmethod
|
||||
def bundle_secret_name(provider_id: str, context: dict | None = None,
|
||||
per_user_keys: list[str] | None = None) -> str:
|
||||
# Default (no per_user_keys) preserves the original single-account name.
|
||||
return f"oauth_token__{provider_id}" + AuthResolver._per_user_suffix(context, per_user_keys)
|
||||
|
||||
async def _store_bundle(self, tenant_id: str, project_id: str, provider_id: str, bundle: dict,
|
||||
*, name: str | None = None) -> None:
|
||||
async with self._sf() as session:
|
||||
await self.secrets.write(
|
||||
session, tenant_id=tenant_id, project_id=project_id,
|
||||
name=name or self.bundle_secret_name(provider_id), value=bundle, kind="oauth",
|
||||
)
|
||||
|
||||
async def _oauth2_auth_code(
|
||||
self, provider, cfg: dict, read, tenant_id: str, project_id: str,
|
||||
client: httpx.AsyncClient | None, context: dict | None = None
|
||||
) -> ResolvedAuth:
|
||||
# Per-user bundle name when the provider keys tokens per end-user (finding i).
|
||||
per_user = cfg.get("per_user_context_keys")
|
||||
bundle_name = self.bundle_secret_name(provider.id, context, per_user)
|
||||
bundle_ref = cfg.get("token_bundle_ref") or f"secret://proj/{bundle_name}"
|
||||
bundle = await read(bundle_ref)
|
||||
if not isinstance(bundle, dict) or not bundle.get("access_token"):
|
||||
raise KeyError(f"OAuth provider {provider.id!r} is not connected - run the connect flow first")
|
||||
now = time.time()
|
||||
expires_at = bundle.get("expires_at")
|
||||
if expires_at and now >= (float(expires_at) - 60) and bundle.get("refresh_token"):
|
||||
# Serialize refresh per (tenant, provider, per-user bundle) so concurrent resolves
|
||||
# don't race the one-time refresh_token; re-read inside the lock in case a peer
|
||||
# already refreshed it.
|
||||
lock = await _oauth_refresh_locks.acquire_cm(f"{tenant_id}:{provider.id}:{bundle_name}")
|
||||
async with lock:
|
||||
fresh = await read(bundle_ref)
|
||||
if isinstance(fresh, dict) and fresh.get("access_token"):
|
||||
bundle = fresh
|
||||
expires_at = bundle.get("expires_at")
|
||||
if expires_at and time.time() >= (float(expires_at) - 60) and bundle.get("refresh_token"):
|
||||
bundle = await self._refresh_oauth(provider, cfg, read, bundle, tenant_id,
|
||||
project_id, client, bundle_name=bundle_name)
|
||||
expires_at = bundle.get("expires_at")
|
||||
header = cfg.get("header_name", "Authorization")
|
||||
prefix = cfg.get("prefix", "Bearer ")
|
||||
ttl_left = (float(expires_at) - now) if expires_at else None
|
||||
cache_exp = time.monotonic() + max(0.0, ttl_left - 60) if ttl_left and ttl_left > 0 else None
|
||||
return ResolvedAuth(headers={header: prefix + str(bundle["access_token"])}, expires_at=cache_exp)
|
||||
|
||||
async def _refresh_oauth(self, provider, cfg: dict, read, bundle: dict, tenant_id, project_id,
|
||||
client, *, bundle_name: str | None = None) -> dict:
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": bundle["refresh_token"],
|
||||
"client_id": await read(cfg.get("client_id_ref")),
|
||||
"client_secret": await read(cfg.get("client_secret_ref")),
|
||||
}
|
||||
client = client or shared_async_client()
|
||||
r = await guarded_request(
|
||||
client, "POST", cfg["token_url"],
|
||||
data={k: v for k, v in data.items() if v is not None}, timeout=30, follow_redirects=True,
|
||||
)
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
new = dict(bundle)
|
||||
new["access_token"] = body.get("access_token", bundle["access_token"])
|
||||
if body.get("refresh_token"):
|
||||
new["refresh_token"] = body["refresh_token"]
|
||||
if body.get("expires_in"):
|
||||
new["expires_at"] = time.time() + int(body["expires_in"])
|
||||
await self._store_bundle(tenant_id, project_id, provider.id, new, name=bundle_name)
|
||||
return new
|
||||
|
||||
async def _csrf_session(self, cfg: dict, vars: dict, client: httpx.AsyncClient | None, default_ttl: int) -> ResolvedAuth:
|
||||
fetch = render_value(cfg["token_fetch"], vars)
|
||||
client = client or shared_async_client()
|
||||
r = await guarded_request(
|
||||
client, fetch["method"], fetch["url"],
|
||||
headers=fetch.get("headers"), json=fetch.get("body"), timeout=30, follow_redirects=True,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
extracted: dict[str, Any] = {}
|
||||
ttl = None
|
||||
for rule in cfg.get("extract", []):
|
||||
val = extract_value(r, rule)
|
||||
if rule.get("kind") == "ttl":
|
||||
ttl = int(val) if val else None
|
||||
else:
|
||||
extracted[rule["name"]] = val
|
||||
|
||||
out = ResolvedAuth(expires_at=time.monotonic() + (ttl or default_ttl))
|
||||
for rule in cfg.get("inject", []):
|
||||
value = render_value(rule["value"], {"extracted": extracted})
|
||||
where = rule["to"]
|
||||
target = {"header": out.headers, "cookie": out.cookies, "query": out.params}[where]
|
||||
target[rule["name"]] = str(value)
|
||||
return out
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Token-template rendering for auth recipes.
|
||||
|
||||
Supports `{{a.b}}` references resolved against a vars dict, e.g.
|
||||
`{{cred.username}}`, `{{ctx.csrf}}`, `{{extracted.session}}`. Non-string leaves
|
||||
pass through; whole-string matches preserve the resolved value's native type.
|
||||
|
||||
`render_value` walks a parsed JSON structure (dict/list) and additionally honors a
|
||||
`{"$each": "{{input.rows}}", "$as": "row", "$do": {...}}` loop directive, so a JSON body
|
||||
template can build a variable-length array from one list-valued arg (see `_render_each`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Collection
|
||||
from typing import Any
|
||||
|
||||
_TOKEN = re.compile(r"\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}")
|
||||
|
||||
|
||||
class MissingTemplateVar(ValueError):
|
||||
"""A template referenced a variable in a STRICT namespace that isn't defined.
|
||||
|
||||
Raised for `{{env.*}}` when the key isn't in FORGE_TOOL_VARS for the current environment,
|
||||
so a misconfigured deploy fails loudly at call/test time instead of sending a broken URL.
|
||||
The lenient namespaces (ctx/input) keep their historic behavior: a missing key renders empty
|
||||
/ is dropped."""
|
||||
|
||||
|
||||
def _lookup(path: str, vars: dict, strict_ns: Collection[str] = ()) -> Any:
|
||||
parts = path.split(".")
|
||||
cur: Any = vars
|
||||
for part in parts:
|
||||
if isinstance(cur, dict):
|
||||
cur = cur.get(part)
|
||||
else:
|
||||
cur = None
|
||||
break
|
||||
# A missing value under a STRICT namespace (e.g. env) is an error, not an empty string.
|
||||
if cur is None and parts and parts[0] in strict_ns:
|
||||
raise MissingTemplateVar(f"undefined template variable {{{{{path}}}}} (namespace '{parts[0]}')")
|
||||
return cur
|
||||
|
||||
|
||||
def _sub_one(mm: re.Match, vars: dict, strict_ns: Collection[str] = ()) -> str:
|
||||
# Embedded token (not a whole-string match): stringify the resolved value. Only a missing
|
||||
# value (None) becomes empty - a falsy-but-real value like 0 or False must render as "0"/
|
||||
# "False", not "" (an `x or ""` here would silently drop legitimate zeros/booleans).
|
||||
v = _lookup(mm.group(1), vars, strict_ns)
|
||||
return "" if v is None else str(v)
|
||||
|
||||
|
||||
def render_template(s: str, vars: dict, *, strict_ns: Collection[str] = ()) -> Any:
|
||||
# `strict_ns` names namespaces whose missing keys raise MissingTemplateVar instead of
|
||||
# rendering empty (used for {{env.*}}). Defaults to lenient for all namespaces.
|
||||
# Whole-string single token -> preserve native type (numbers, objects).
|
||||
m = _TOKEN.fullmatch(s.strip())
|
||||
if m:
|
||||
return _lookup(m.group(1), vars, strict_ns)
|
||||
return _TOKEN.sub(lambda mm: _sub_one(mm, vars, strict_ns), s)
|
||||
|
||||
|
||||
def has_each_directive(obj: Any) -> bool:
|
||||
"""True if `obj` (a parsed JSON structure) contains a `$each` loop directive anywhere - i.e.
|
||||
a dict that has "$each" as a KEY. Used to decide whether a body template needs structural
|
||||
rendering; a literal "$each" appearing inside a string value is NOT a directive and must not
|
||||
trigger it (that would silently change type coercion for unrelated templates)."""
|
||||
if isinstance(obj, dict):
|
||||
if "$each" in obj:
|
||||
return True
|
||||
return any(has_each_directive(v) for v in obj.values())
|
||||
if isinstance(obj, list):
|
||||
return any(has_each_directive(v) for v in obj)
|
||||
return False
|
||||
|
||||
|
||||
def render_value(obj: Any, vars: dict, *, allow_each: bool = False, strict_ns: Collection[str] = ()) -> Any:
|
||||
"""Walk a parsed JSON structure, rendering `{{token}}` leaves. `$each` loop directives are
|
||||
honored ONLY when `allow_each=True` (the REST body-template path opts in); every other caller
|
||||
- auth token_fetch/extract rules, data-node payloads - passes the default False, so a literal
|
||||
object key named "$each" stays an ordinary key instead of being reinterpreted as a loop.
|
||||
`strict_ns` propagates the fail-loud namespaces (e.g. env) to every leaf."""
|
||||
if isinstance(obj, str):
|
||||
return render_template(obj, vars, strict_ns=strict_ns)
|
||||
if isinstance(obj, dict):
|
||||
if allow_each and "$each" in obj:
|
||||
return _render_each(obj, vars, strict_ns=strict_ns)
|
||||
return {k: render_value(v, vars, allow_each=allow_each, strict_ns=strict_ns) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [render_value(v, vars, allow_each=allow_each, strict_ns=strict_ns) for v in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def _render_each(directive: dict, vars: dict, *, strict_ns: Collection[str] = ()) -> list:
|
||||
"""Expand a `{"$each": "{{input.rows}}", "$as": "row", "$do": {...}}` loop directive into a
|
||||
list: render `$do` once per item of the array `$each` resolves to, with the item bound under
|
||||
the `$as` name (default "item"). Outer vars (input/ctx/state) stay visible inside the loop, so
|
||||
a nested template can still read e.g. `{{input.orderId}}`. A missing/None `$each` yields [];
|
||||
a single non-list value is treated as one item.
|
||||
|
||||
This lets a JSON body template build a variable-length array (e.g. one productRow per edited
|
||||
cell) WITHOUT string-concatenating JSON - so the output is always valid JSON with native types
|
||||
preserved, and one tool call can carry many rows instead of one call per row.
|
||||
"""
|
||||
each = directive.get("$each")
|
||||
seq = render_value(each, vars, strict_ns=strict_ns) if isinstance(each, str) else each
|
||||
if seq is None:
|
||||
items: list = []
|
||||
elif isinstance(seq, list):
|
||||
items = seq
|
||||
else:
|
||||
items = [seq]
|
||||
as_name = directive.get("$as") or "item"
|
||||
body = directive.get("$do")
|
||||
# allow_each=True so a `$do` body can itself contain a nested `$each` (loops within loops).
|
||||
return [render_value(body, {**vars, as_name: item}, allow_each=True, strict_ns=strict_ns) for item in items]
|
||||
@@ -0,0 +1 @@
|
||||
"""Channel adapters."""
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Email channel - inbound parsing and outbound (SMTP) replies.
|
||||
|
||||
Inbound supports two shapes:
|
||||
- A raw RFC-822 message (bytes/str), e.g. from an IMAP poll.
|
||||
- A provider inbound-parse payload (Mailgun/SendGrid/Postmark post form/JSON fields).
|
||||
|
||||
Outbound sends a threaded reply via SMTP (creds resolved from the channel's secret
|
||||
refs). SMTP/IMAP run in worker threads so they don't block the event loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import email
|
||||
import logging
|
||||
import re
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from email.utils import make_msgid, parseaddr
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any
|
||||
|
||||
from forge.channels.retry import retry_send
|
||||
from forge.secrets.store import SecretStore
|
||||
|
||||
log = logging.getLogger("forge.channels.email")
|
||||
|
||||
_BLOCK_ENDERS = {"br", "p", "div", "li", "tr"}
|
||||
_SKIP_TAGS = {"script", "style"}
|
||||
|
||||
|
||||
class _HTMLTextExtractor(HTMLParser):
|
||||
"""Pull visible text out of HTML with the stdlib parser instead of regexes, so a hostile
|
||||
body can't trigger catastrophic backtracking (ReDoS): skip <script>/<style> content, turn
|
||||
common block-enders into newlines, and let the parser unescape entities (convert_charrefs)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self._parts: list[str] = []
|
||||
self._skip_depth = 0
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: Any) -> None:
|
||||
if tag in _SKIP_TAGS:
|
||||
self._skip_depth += 1
|
||||
elif tag == "br":
|
||||
self._parts.append("\n")
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: Any) -> None:
|
||||
if tag == "br":
|
||||
self._parts.append("\n")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag in _SKIP_TAGS:
|
||||
if self._skip_depth:
|
||||
self._skip_depth -= 1
|
||||
elif tag in _BLOCK_ENDERS:
|
||||
self._parts.append("\n")
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if not self._skip_depth:
|
||||
self._parts.append(data)
|
||||
|
||||
def get_text(self) -> str:
|
||||
return "".join(self._parts)
|
||||
|
||||
|
||||
def _html_to_text(html: str) -> str:
|
||||
"""Best-effort HTML -> plain text WITHOUT a new dependency, so an HTML-only email's body
|
||||
reaches the workflow instead of an empty string (audit F). Parser-based (not regex) to stay
|
||||
ReDoS-safe on attacker-controlled input."""
|
||||
if not html:
|
||||
return ""
|
||||
parser = _HTMLTextExtractor()
|
||||
try:
|
||||
parser.feed(html)
|
||||
parser.close()
|
||||
except Exception: # noqa: BLE001 - malformed HTML must never crash the public inbound webhook
|
||||
pass
|
||||
text = parser.get_text()
|
||||
# collapse horizontal whitespace and excess blank lines; every pattern here is linear.
|
||||
text = re.sub(r"[ \t\f\v]+", " ", text)
|
||||
text = re.sub(r" *\n *", "\n", text)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _thread_ref(references: str | None, in_reply_to: str | None, message_id: str | None) -> str | None:
|
||||
"""A stable per-conversation key for threading replies (audit F6): the ROOT of the
|
||||
References chain (shared by every message in the thread), else the In-Reply-To parent,
|
||||
else this message's own id (a brand-new thread)."""
|
||||
if references:
|
||||
first = references.split()[0].strip() if references.split() else ""
|
||||
if first:
|
||||
return first
|
||||
return (in_reply_to or "").strip() or (message_id or "").strip() or None
|
||||
|
||||
|
||||
def _merge_references(references: str | None, parent_id: str | None) -> str:
|
||||
"""The outbound `References` header: the inbound References chain with the parent's
|
||||
Message-ID appended (RFC 5322). Clients thread on the full References chain, not just
|
||||
In-Reply-To, so preserving the whole chain keeps deep threads together (audit G)."""
|
||||
ids: list[str] = []
|
||||
for tok in (references or "").split():
|
||||
tok = tok.strip()
|
||||
if tok and tok not in ids:
|
||||
ids.append(tok)
|
||||
if parent_id:
|
||||
pid = parent_id.strip()
|
||||
if pid and pid not in ids:
|
||||
ids.append(pid)
|
||||
return " ".join(ids)
|
||||
|
||||
|
||||
def parse_inbound(payload: Any) -> dict:
|
||||
"""Normalize an inbound email (raw MIME or provider dict) to a common shape."""
|
||||
if isinstance(payload, dict):
|
||||
# Provider inbound-parse fields vary; accept the common ones.
|
||||
sender = payload.get("from") or payload.get("sender") or payload.get("From", "")
|
||||
subject = payload.get("subject") or payload.get("Subject", "")
|
||||
text = payload.get("text") or payload.get("body-plain") or payload.get("stripped-text") or payload.get("TextBody") or ""
|
||||
if not (text or "").strip():
|
||||
# HTML-only email: fall back to the provider's HTML field, stripped to text, so the
|
||||
# workflow gets the body instead of an empty message (audit F).
|
||||
html = payload.get("html") or payload.get("body-html") or payload.get("HtmlBody") or payload.get("stripped-html") or ""
|
||||
text = _html_to_text(html)
|
||||
msg_id = payload.get("message-id") or payload.get("Message-Id") or payload.get("MessageID")
|
||||
references = payload.get("References") or payload.get("references")
|
||||
in_reply_to = payload.get("In-Reply-To") or payload.get("in-reply-to")
|
||||
return {"from_addr": parseaddr(sender)[1] or sender, "from_name": parseaddr(sender)[0],
|
||||
"subject": subject, "text": (text or "").strip(), "message_id": msg_id,
|
||||
"references": references,
|
||||
"thread_ref": _thread_ref(references, in_reply_to, msg_id)}
|
||||
|
||||
raw = payload.encode() if isinstance(payload, str) else payload
|
||||
msg = email.message_from_bytes(raw)
|
||||
body = ""
|
||||
html_body = ""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
ctype = part.get_content_type()
|
||||
if "attachment" in str(part.get("Content-Disposition", "")):
|
||||
continue
|
||||
# decode=True returns None for a part with no decodable payload - guard it so a
|
||||
# malformed MIME message can't 500 the public inbound webhook (audit F-low).
|
||||
if ctype == "text/plain" and not body:
|
||||
raw_payload = part.get_payload(decode=True) or b""
|
||||
body = raw_payload.decode(part.get_content_charset() or "utf-8", "replace")
|
||||
elif ctype == "text/html" and not html_body:
|
||||
raw_payload = part.get_payload(decode=True) or b""
|
||||
html_body = raw_payload.decode(part.get_content_charset() or "utf-8", "replace")
|
||||
else:
|
||||
payload_bytes = msg.get_payload(decode=True) or b""
|
||||
decoded = payload_bytes.decode(msg.get_content_charset() or "utf-8", "replace")
|
||||
if msg.get_content_type() == "text/html":
|
||||
html_body = decoded
|
||||
else:
|
||||
body = decoded
|
||||
# No text/plain part -> fall back to the HTML part, stripped to text (audit F).
|
||||
if not body.strip() and html_body:
|
||||
body = _html_to_text(html_body)
|
||||
name, addr = parseaddr(msg.get("From", ""))
|
||||
return {"from_addr": addr, "from_name": name, "subject": msg.get("Subject", ""),
|
||||
"text": body.strip(), "message_id": msg.get("Message-ID"),
|
||||
"references": msg.get("References"),
|
||||
"thread_ref": _thread_ref(msg.get("References"), msg.get("In-Reply-To"), msg.get("Message-ID"))}
|
||||
|
||||
|
||||
def build_input_text(parsed: dict, include_subject: bool = True) -> str:
|
||||
if include_subject and parsed.get("subject"):
|
||||
return f"Subject: {parsed['subject']}\n\n{parsed.get('text', '')}"
|
||||
return parsed.get("text", "")
|
||||
|
||||
|
||||
def build_reply(*, to_addr: str, subject: str, body: str, from_addr: str,
|
||||
in_reply_to: str | None = None, references: str | None = None,
|
||||
message_id: str | None = None) -> EmailMessage:
|
||||
msg = EmailMessage()
|
||||
msg["From"] = from_addr
|
||||
msg["To"] = to_addr
|
||||
msg["Subject"] = subject if subject.lower().startswith("re:") else f"Re: {subject}" if subject else "Re:"
|
||||
if in_reply_to:
|
||||
msg["In-Reply-To"] = in_reply_to
|
||||
# References = the inbound chain + the parent id, so deep threads stay grouped (audit G).
|
||||
refs = _merge_references(references, in_reply_to)
|
||||
if refs:
|
||||
msg["References"] = refs
|
||||
# Set an explicit Message-ID so a downstream reply can reference THIS message and clients
|
||||
# can dedupe (a missing Message-ID makes some MTAs generate an unstable one) (audit G).
|
||||
try:
|
||||
msg["Message-ID"] = message_id or make_msgid()
|
||||
except Exception: # noqa: BLE001 - make_msgid can fail on odd hostnames; header is optional
|
||||
pass
|
||||
msg.set_content(body)
|
||||
return msg
|
||||
|
||||
|
||||
def _send_sync(host: str, port: int, username: str | None, password: str | None, use_tls: bool, msg: EmailMessage) -> None:
|
||||
with smtplib.SMTP(host, port, timeout=30) as server:
|
||||
if use_tls:
|
||||
server.starttls()
|
||||
if username and password:
|
||||
server.login(username, password)
|
||||
server.send_message(msg)
|
||||
|
||||
|
||||
async def send_reply(channel, parsed: dict, answer: str) -> bool:
|
||||
"""Send the workflow's answer as a threaded SMTP reply, with bounded retry + backoff.
|
||||
|
||||
Returns True when an email was actually sent, False when SMTP isn't configured / there's
|
||||
nowhere to send (a no-op, NOT a failure). Raises after exhausting retries so the caller can
|
||||
record a real delivery status and avoid marking a handoff 'answered' on a failed send (E)."""
|
||||
cfg = channel.config or {}
|
||||
smtp = cfg.get("smtp") or {}
|
||||
host = smtp.get("host")
|
||||
if not host or not parsed.get("from_addr"):
|
||||
return False
|
||||
secrets = SecretStore()
|
||||
username = smtp.get("username")
|
||||
password = None
|
||||
if smtp.get("password_ref"):
|
||||
try:
|
||||
password = await secrets.read_ref(tenant_id=channel.tenant_id, project_id=channel.project_id, ref=smtp["password_ref"])
|
||||
except Exception: # noqa: BLE001
|
||||
password = None
|
||||
from_addr = smtp.get("from") or username or "bot@forge.local"
|
||||
msg = build_reply(to_addr=parsed["from_addr"], subject=parsed.get("subject", ""), body=answer,
|
||||
from_addr=from_addr, in_reply_to=parsed.get("message_id"),
|
||||
references=parsed.get("references"))
|
||||
port = int(smtp.get("port", 587))
|
||||
use_tls = bool(smtp.get("use_tls", True))
|
||||
pw = str(password) if password else None
|
||||
|
||||
async def _attempt(_n: int) -> bool:
|
||||
await asyncio.to_thread(_send_sync, host, port, username, pw, use_tls, msg)
|
||||
return True
|
||||
|
||||
await retry_send(_attempt, label=f"email->{parsed['from_addr']}")
|
||||
return True
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Bounded retry + backoff for outbound channel delivery (audit E).
|
||||
|
||||
Outbound email (SMTP) deliveries are transient-failure prone
|
||||
(a relay hiccup, a 429, a 5xx). Previously a single failure was swallowed and the reply was
|
||||
lost - and worse, a handoff was still marked 'answered'. `retry_send` gives every outbound
|
||||
delivery a small, jittered exponential backoff and re-raises the last error when exhausted so
|
||||
the caller can record a real delivery status.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TypeVar
|
||||
|
||||
from forge.config import settings
|
||||
|
||||
log = logging.getLogger("forge.channels.retry")
|
||||
|
||||
# Outbound-delivery retry policy (env-overridable: FORGE_CHANNEL_SEND_MAX_ATTEMPTS /
|
||||
# _BACKOFF_BASE_SECONDS).
|
||||
CHANNEL_SEND_MAX_ATTEMPTS = settings.channel_send_max_attempts
|
||||
CHANNEL_SEND_BACKOFF_BASE_SECONDS = settings.channel_send_backoff_base_seconds
|
||||
CHANNEL_SEND_MAX_BACKOFF_SECONDS = 8.0
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ChannelDeliveryError(Exception):
|
||||
"""A retryable outbound-delivery failure. `retry_after` (seconds), when set, overrides the
|
||||
computed backoff for the next attempt - used to honor an HTTP `Retry-After` on a 429."""
|
||||
|
||||
def __init__(self, message: str, *, retry_after: float | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
async def retry_send(
|
||||
fn: Callable[[int], Awaitable[T]],
|
||||
*,
|
||||
attempts: int = CHANNEL_SEND_MAX_ATTEMPTS,
|
||||
base_delay: float = CHANNEL_SEND_BACKOFF_BASE_SECONDS,
|
||||
label: str = "channel send",
|
||||
) -> T:
|
||||
"""Call async `fn(attempt)` up to `attempts` times with jittered exponential backoff.
|
||||
|
||||
`fn` raises to signal a retryable failure; raise `ChannelDeliveryError(retry_after=…)` to
|
||||
request an explicit wait (server-directed, e.g. a 429). Returns `fn`'s result on the first
|
||||
success; re-raises the last exception once attempts are exhausted."""
|
||||
last_exc: BaseException | None = None
|
||||
for attempt in range(1, max(1, attempts) + 1):
|
||||
try:
|
||||
return await fn(attempt)
|
||||
except Exception as e: # noqa: BLE001 - classify + backoff below
|
||||
last_exc = e
|
||||
if attempt >= attempts:
|
||||
break
|
||||
retry_after = getattr(e, "retry_after", None)
|
||||
if retry_after is not None:
|
||||
delay = float(retry_after)
|
||||
else:
|
||||
delay = min(base_delay * (2 ** (attempt - 1)), CHANNEL_SEND_MAX_BACKOFF_SECONDS)
|
||||
delay += random.uniform(0, base_delay) # noqa: S311 - jitter, not crypto
|
||||
log.warning(
|
||||
"%s attempt %d/%d failed (%s); retrying in %.1fs",
|
||||
label, attempt, attempts, type(e).__name__, delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
@@ -0,0 +1,537 @@
|
||||
"""Application settings - environment-driven (pydantic-settings).
|
||||
|
||||
Local defaults need **no external infra** (SQLite + embedded Chroma + in-process cache).
|
||||
Every value can be overridden via `.env` or real env vars; the production swaps
|
||||
(Postgres, Redis, Vault) are pure configuration changes - no code changes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
|
||||
# Repo layout: apps/api/forge/config.py -> parents[3] == repo root. In the container the
|
||||
# tree is flattened to /app/forge, so parents[3] doesn't exist; fall back to API_ROOT (=/app).
|
||||
# The Docker image bakes the schemas at /app/packages/schemas (apps/api/Dockerfile:
|
||||
# `COPY packages/schemas ./packages/schemas`), so the default _DEFAULT_SCHEMAS_DIR
|
||||
# (/app/packages/schemas) resolves with no FORGE_SCHEMAS_DIR override. Set FORGE_SCHEMAS_DIR
|
||||
# only to point at an out-of-tree schemas copy.
|
||||
_HERE = Path(__file__).resolve()
|
||||
API_ROOT = _HERE.parents[1]
|
||||
REPO_ROOT = _HERE.parents[3] if len(_HERE.parents) > 3 else API_ROOT
|
||||
_DEFAULT_SCHEMAS_DIR = REPO_ROOT / "packages" / "schemas"
|
||||
_DEFAULT_DATA_DIR = API_ROOT / ".data"
|
||||
|
||||
# Minimum length for a non-empty FORGE_SERVICE_API_TOKEN (enforced by the production guard).
|
||||
_MIN_SERVICE_TOKEN_LEN = 24
|
||||
|
||||
|
||||
def _as_str_list(v: object) -> list[str]:
|
||||
"""Parse a list-of-strings setting from env leniently: a JSON array (["a","b"]), a
|
||||
comma-separated string (a,b), or blank ("" -> []). Env/compose quoting makes strict-JSON
|
||||
list fields brittle - a stray bracket or space (e.g. from a ${VAR:-[]} interpolation)
|
||||
otherwise crashes startup - so we normalize here instead of requiring valid JSON."""
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, (list, tuple)):
|
||||
return [str(x) for x in v]
|
||||
s = str(v).strip()
|
||||
if not s:
|
||||
return []
|
||||
if s.startswith("["):
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
parsed = _json.loads(s)
|
||||
if isinstance(parsed, list):
|
||||
return [str(x).strip() for x in parsed]
|
||||
except ValueError:
|
||||
pass # not valid JSON (e.g. unquoted, or a mangled "[") -> strip brackets + split
|
||||
s = s.strip("[]")
|
||||
return [p.strip().strip('"').strip("'") for p in s.split(",") if p.strip().strip('"').strip("'")]
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="FORGE_",
|
||||
# ONE .env, at the repo root - the same file docker-compose reads for ${...}
|
||||
# substitution - so both run modes (.venv api and the Docker stack) are configured in a
|
||||
# single place. In the flattened container image REPO_ROOT == /app (no .env is copied
|
||||
# there); env then comes from the compose `environment:` block and pydantic simply skips
|
||||
# the missing file. Real env vars still take precedence over the file either way.
|
||||
env_file=(REPO_ROOT / ".env"),
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# List-of-strings settings are marked NoDecode (skip pydantic-settings' strict JSON decode)
|
||||
# and parsed here so env/compose values may be JSON, comma-separated, or blank - see
|
||||
# _as_str_list. Keeps a stray bracket/space from an interpolated default from crashing boot.
|
||||
@field_validator(
|
||||
"jwt_secret_previous", "cors_origins", "egress_allow_hosts", "egress_deny_hosts",
|
||||
"egress_allow_private_hosts", "trusted_proxies", "trusted_hosts",
|
||||
"mcp_stdio_allowed_commands", mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def _parse_str_lists(cls, v: object) -> list[str]:
|
||||
return _as_str_list(v)
|
||||
|
||||
# tool_vars is a JSON MAP (not a list), so it has its own before-validator: an env var may be
|
||||
# unset/blank (-> {}) or a JSON object string. Marked NoDecode so pydantic-settings does not
|
||||
# crash on a blank value before we get here; a malformed JSON object still fails loudly.
|
||||
@field_validator("tool_vars", mode="before")
|
||||
@classmethod
|
||||
def _parse_tool_vars(cls, v: object) -> dict[str, str]:
|
||||
if v is None:
|
||||
return {}
|
||||
if isinstance(v, dict):
|
||||
return {str(k): str(val) for k, val in v.items()}
|
||||
s = str(v).strip()
|
||||
if not s:
|
||||
return {}
|
||||
import json as _json
|
||||
|
||||
parsed = _json.loads(s)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError('FORGE_TOOL_VARS must be a JSON object, e.g. {"api_base":"https://..."}')
|
||||
return {str(k): str(val) for k, val in parsed.items()}
|
||||
|
||||
# --- App ---
|
||||
app_name: str = "Forge"
|
||||
environment: str = "development"
|
||||
debug: bool = True
|
||||
api_v1_prefix: str = "/v1"
|
||||
|
||||
# --- Persistence (SQLite default; Postgres is a config-only swap) ---
|
||||
database_url: str = Field(
|
||||
default_factory=lambda: f"sqlite+aiosqlite:///{(_DEFAULT_DATA_DIR / 'forge.db').as_posix()}"
|
||||
)
|
||||
# LangGraph durable-execution checkpointer (sqlite file or 'memory').
|
||||
checkpoint_db: str = Field(
|
||||
default_factory=lambda: (_DEFAULT_DATA_DIR / "checkpoints.sqlite").as_posix()
|
||||
)
|
||||
|
||||
# --- Vectors ---
|
||||
# Backend for the embedding store. "chroma" (default) is the embedded persistent client -
|
||||
# zero-infra, but single-writer (one process owns the on-disk index), so it does NOT share
|
||||
# across workers. "pgvector" stores vectors in Postgres (reusing `database_url`) behind the
|
||||
# same interface, so every worker reads/writes the same vectors - the production choice.
|
||||
# pgvector requires Postgres with the `vector` extension available.
|
||||
vector_backend: str = Field(default="chroma") # chroma | pgvector
|
||||
chroma_path: str = Field(default_factory=lambda: (_DEFAULT_DATA_DIR / "chroma").as_posix())
|
||||
# Cache dir for the local fastembed embedder's model files. Set to a baked path in the
|
||||
# Docker image (see apps/api/Dockerfile) so the default model ships with the image - no
|
||||
# first-run download / network dependency. None -> fastembed's own default (a temp dir),
|
||||
# fine for local dev.
|
||||
fastembed_cache_dir: str | None = None
|
||||
|
||||
# --- Cache / queue (in-process locally; Redis in prod) ---
|
||||
redis_url: str | None = None # None => in-process fakes
|
||||
|
||||
# --- Secrets (Fernet master key; file-backed locally, KMS/Vault in prod) ---
|
||||
secret_key_file: str = Field(default_factory=lambda: (_DEFAULT_DATA_DIR / "master.key").as_posix())
|
||||
|
||||
# --- Platform auth (JWT) ---
|
||||
jwt_secret: str = "dev-insecure-change-me"
|
||||
# Previously-active signing secrets, still ACCEPTED for verification (not for minting),
|
||||
# so you can rotate `jwt_secret` without invalidating every live token: set the new key
|
||||
# here-as-previous during the overlap window, then drop it. Tokens carry a `kid` header.
|
||||
jwt_secret_previous: Annotated[list[str], NoDecode] = []
|
||||
jwt_key_id: str = "k1"
|
||||
jwt_algorithm: str = "HS256"
|
||||
# Shorter access-token lifetime bounds the blast radius of a leaked token (audit S11);
|
||||
# the 30-day refresh token (rotated on use) keeps sessions alive without re-login.
|
||||
access_token_ttl_minutes: int = 60 * 8
|
||||
refresh_token_ttl_days: int = 30
|
||||
# Static service token for trusted server-to-server integrations (e.g. an app backend that
|
||||
# drives runs on behalf of its users). Sent as `Authorization: Bearer <token>`; when it
|
||||
# matches, the request authenticates as a least-privilege (editor) service identity in the
|
||||
# seeded workspace - no expiry, revoke by rotating this value. Empty = disabled. This is the
|
||||
# outer "is this call from our backend?" barrier; per-user / per-API auth is handled
|
||||
# separately (e.g. session+CSRF injected per-run into tools). Keep it long, random, secret.
|
||||
service_api_token: str = ""
|
||||
# Auth is ON by default - the app behaves like production (real login required), so the
|
||||
# flow is actually exercised in dev. The seeded owner (bootstrap_admin_email/password
|
||||
# below) lets you log in immediately; self-service signup creates additional workspaces.
|
||||
# (Public surfaces - webhooks/MCP/OAuth callback - authenticate by their own key,
|
||||
# never the JWT, so they keep working regardless.)
|
||||
auth_required: bool = True
|
||||
# Allow open self-service signup. When False, only an existing admin can invite users.
|
||||
allow_open_signup: bool = True
|
||||
# Public base URL of THIS API (for OAuth redirect URIs + channel webhooks). Must match
|
||||
# what you register with each OAuth provider, e.g. https://forge.yourco.com.
|
||||
public_base_url: str = "http://localhost:8000"
|
||||
# Public URL of the web console (where the SPA is served). Used to build invite links
|
||||
# emailed to new teammates, e.g. https://app.forge.yourco.com.
|
||||
public_console_url: str = "http://localhost:3000"
|
||||
# Expose Forge's MCP server as an OAuth 2.1 resource + authorization server (MCP authorization
|
||||
# spec). OFF by default: the /.well-known discovery, register/authorize/token endpoints, and the
|
||||
# OAuth 401 challenge are only served when enabled, so an operator opts in AFTER reviewing the
|
||||
# flow (consent UX, registered-client trust, MFA/multi-workspace login edge cases).
|
||||
mcp_oauth_enabled: bool = False
|
||||
|
||||
# Deployment-wide fallback for a per-user auth provider's `token_ctx_key`. When a per-user
|
||||
# bearer provider does NOT set its own `token_ctx_key`, the resolver reads the forwarded inline
|
||||
# token from THIS run-context key (sent by a server-to-server caller in `X-Forge-Context`). Lets
|
||||
# an integration that always forwards the same key (e.g. {"user_token": "..."}) work in EVERY
|
||||
# project without configuring each provider by hand (survives project re-creation).
|
||||
# Empty/None = off (per-provider `token_ctx_key` only). Env: FORGE_DEFAULT_TOKEN_CTX_KEY.
|
||||
default_token_ctx_key: str | None = None
|
||||
|
||||
# --- Outbound email (SMTP) - used for team invites & notifications. When smtp_host is
|
||||
# unset, email sending is a no-op and the API returns the invite link so an admin can
|
||||
# share it manually. Point at any SMTP relay (Postmark/SendGrid/SES/Mailgun/etc.). ---
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int = 587
|
||||
smtp_username: str | None = None
|
||||
smtp_password: str | None = None
|
||||
smtp_use_tls: bool = True
|
||||
smtp_from: str = "Forge <no-reply@forge.local>"
|
||||
# The seeded workspace owner. The default password lets you log in straight away in dev
|
||||
# (email: you@forge.local · password: forge-admin); CHANGE IT in production (the prod
|
||||
# guard rejects the default password).
|
||||
bootstrap_admin_email: str = "you@forge.local"
|
||||
bootstrap_admin_password: str | None = "forge-admin"
|
||||
|
||||
# --- Schemas (shared contract, packages/schemas) ---
|
||||
schemas_dir: str = Field(default_factory=lambda: _DEFAULT_SCHEMAS_DIR.as_posix())
|
||||
|
||||
# --- Tools ---
|
||||
# Per-environment substitution values exposed to tool/auth endpoint templates as {{env.*}}
|
||||
# (e.g. {{env.api_base}} in a REST tool's url_template, or a GraphQL endpoint). ONE JSON map,
|
||||
# so the SAME tool/auth DB row works in every environment - only this value changes per deploy.
|
||||
# Add one entry per downstream system. A template that references a key NOT in this map fails
|
||||
# the call with a clear error (rather than silently sending a broken URL).
|
||||
# Env: FORGE_TOOL_VARS='{"api_base":"https://api.example.com"}'. Blank/unset = {}.
|
||||
tool_vars: Annotated[dict[str, str], NoDecode] = Field(default_factory=dict)
|
||||
# Code tools run RestrictedPython (AST-sandboxed) but NOT OS-isolated: no CPU/memory
|
||||
# bound and a runaway thread can't be force-killed. RestrictedPython is a hardening
|
||||
# layer, not a sandbox, so it is OFF by default. Only enable it on a trusted, single-
|
||||
# tenant install, or once an isolated executor (subprocess/container/gVisor) is wired
|
||||
# in. The production guard refuses to boot with this on unless explicitly acknowledged.
|
||||
enable_code_tools: bool = False
|
||||
# Set true to run code tools in production despite the lack of OS isolation (you accept
|
||||
# the in-process RCE/DoS risk - e.g. a trusted single-tenant deployment).
|
||||
allow_unsandboxed_code_tools: bool = False
|
||||
# External MCP servers reached via the `stdio` transport launch a LOCAL PROCESS
|
||||
# (command + args) - i.e. arbitrary command execution on the API host, like an
|
||||
# unsandboxed code tool. OFF by default; enable only on a trusted single-tenant install.
|
||||
# Optionally restrict to an allow-list of executables (empty = any command when enabled).
|
||||
enable_mcp_stdio: bool = False
|
||||
mcp_stdio_allowed_commands: Annotated[list[str], NoDecode] = []
|
||||
# Prune the assistant's ~19 tools to the relevant subset per turn (cuts tool-schema
|
||||
# tokens). Opt-in: the selection itself is an extra model call, so it's a tradeoff.
|
||||
assistant_tool_selector: bool = False
|
||||
# Hard cap on a tool response handed to the model when no projection trims it
|
||||
# (token-cost guard). 0 = no cap.
|
||||
max_tool_response_chars: int = 20000
|
||||
# Default per-request timeout (seconds) for REST/HTTP tools when the tool config doesn't
|
||||
# set its own `timeout_seconds`.
|
||||
tool_request_timeout_seconds: int = 30
|
||||
# Max redirect hops a REST tool follows when follow_redirects is on. Each hop is
|
||||
# re-validated against the SSRF egress guard, so this bounds a redirect loop / chain.
|
||||
tool_max_redirects: int = 5
|
||||
# Best-effort IPv6/AAAA-lookup suppression fallback (see forge.util.netfix). The AUTHORITATIVE
|
||||
# fix for the slow-cold-DNS penalty is `RES_OPTIONS=no-aaaa` in the process env, set by
|
||||
# docker-compose - it works under uvloop (which the server uses) where a Python resolver patch
|
||||
# cannot. This toggle only enables the code-level fallback for launch paths without that env
|
||||
# var (e.g. a local .venv run). Disable (FORGE_PREFER_IPV4_EGRESS=false) for an IPv6-only egress
|
||||
# network (and clear RES_OPTIONS in that case too).
|
||||
prefer_ipv4_egress: bool = True
|
||||
# Reuse ONE warm keep-alive HTTP connection pool for OpenAI model calls across runs, instead
|
||||
# of the fresh client each per-run graph compile would otherwise build (a new TLS handshake on
|
||||
# the first call of every run). No cost/token impact - purely a transport optimization that
|
||||
# also lowers server load (fewer handshakes/sockets). Off => each model builds its own client.
|
||||
llm_http_keepalive: bool = True
|
||||
# Auto-attach AnthropicPromptCachingMiddleware to Anthropic-model agents (caches the
|
||||
# static system-prompt/tools prefix; large multi-turn cost saving). Off => opt-in only.
|
||||
default_anthropic_prompt_caching: bool = True
|
||||
# Minimum cosine similarity for a long-term-memory `recall` hit to be returned. 0 = off
|
||||
# (return the top_k nearest regardless of distance). Raise (~0.3-0.5 for the default BGE
|
||||
# embedder) to stop unrelated memories polluting the prompt.
|
||||
memory_recall_min_score: float = 0.0
|
||||
|
||||
# --- Semantic cache / HITL / channel delivery (framework-configurable) ---
|
||||
# Cosine threshold for a semantic-cache hit (the `semantic_cache` agent middleware) and its
|
||||
# entry TTL. Per-agent config can override; these are the deployment defaults.
|
||||
semantic_cache_threshold: float = 0.95
|
||||
semantic_cache_ttl_seconds: int = 3600
|
||||
# How long a HITL-interrupted run may wait for a human before the reaper expires it (fails
|
||||
# the run + closes the handoff with the on_error fallback). 0 = never expire (prior behavior).
|
||||
hitl_approval_timeout_seconds: int = 0
|
||||
# Hard per-run wall-clock ceiling (cooperative, checked between stream frames). 0 = unlimited.
|
||||
run_wall_clock_timeout_seconds: int = 0
|
||||
# Outbound channel delivery (email SMTP) retry policy.
|
||||
channel_send_max_attempts: int = 3
|
||||
channel_send_backoff_base_seconds: float = 0.5
|
||||
|
||||
# --- Models ---
|
||||
default_model: str = "fake:echo" # offline-safe default; set a real provider model in prod
|
||||
request_timeout_seconds: int = 600
|
||||
|
||||
# LangGraph checkpoint durability for runs: "async" (default - persist while the
|
||||
# next step executes), "sync" (persist before next step), or "exit" (persist only
|
||||
# at the end; fastest, but HITL interrupts mid-run rely on per-step checkpoints,
|
||||
# so keep async/sync when using human_input nodes).
|
||||
run_durability: str = "async"
|
||||
# Floor for LangGraph's per-run superstep budget (recursion_limit). LangGraph's own
|
||||
# default is 25, which a Loop node (each iteration ~= loop->router->body, ~3 supersteps)
|
||||
# blows past after only a handful of iterations, raising GraphRecursionError mid-run. The
|
||||
# actual limit used is max(this floor, a value derived from workflow size + loop max_iter),
|
||||
# so large graphs/loops scale automatically. Raise the floor for very deep workflows.
|
||||
graph_recursion_limit: int = 100
|
||||
|
||||
# --- Observability (OpenTelemetry export; point at an OTLP collector or Langfuse) ---
|
||||
otel_enabled: bool = False
|
||||
otel_exporter_otlp_endpoint: str | None = None
|
||||
otel_service_name: str = "forge"
|
||||
# Expose the unauthenticated /metrics (Prometheus counters) and /version (dependency
|
||||
# versions) endpoints. OFF by default: these are an internal operational surface that also
|
||||
# aids fingerprinting, so enable only where the scrape endpoint sits on a trusted network.
|
||||
expose_metrics: bool = False
|
||||
|
||||
# --- Tool I/O in traces (debug what an agent actually sent a tool) ---
|
||||
# Master switch: capture per-tool-call input/output on trace spans (the LLM's tool args,
|
||||
# and for REST tools the FRAMED request - method, resolved URL, query, headers, cookies,
|
||||
# body - plus the response status/latency/body). Lets you see whether the agent attached
|
||||
# proper input, and why a call that "works in test" 401s in a run (e.g. a {{ctx.*}} cookie
|
||||
# that never arrived and was silently dropped). Admin-only dashboard surface.
|
||||
trace_tool_io: bool = True
|
||||
# A REST request/response captured for a trace can contain LIVE session cookies, CSRF
|
||||
# tokens, and Authorization headers. Off (default) stores full values for debugging; set
|
||||
# true on a shared/production install to MASK the values of sensitive headers/cookies
|
||||
# (presence + length kept, e.g. "••• (32 chars)") so secrets aren't persisted in traces.
|
||||
trace_tool_io_redact: bool = False
|
||||
# Per-field clip so a large body/response can't bloat the spans table. 0 = no cap.
|
||||
trace_tool_io_max_chars: int = 20000
|
||||
|
||||
# In-process scheduler for `schedule` / `app_event` triggers (fires due ones once a minute).
|
||||
# ON by default so a published schedule actually fires out of the box (the manual documents
|
||||
# this; off-by-default silently no-op'd every schedule). Dispatch now claims each due trigger
|
||||
# atomically (re-check + stamp last_fired_at in one txn) before running it, so an accidental
|
||||
# multi-leader setup can't double-fire. For multi-replica prod, still elect ONE leader:
|
||||
# `scheduler_leader` lets you ship the same image everywhere and set FORGE_SCHEDULER_LEADER
|
||||
# =false on the non-leaders. Set FORGE_ENABLE_SCHEDULER=false to disable entirely.
|
||||
enable_scheduler: bool = True
|
||||
scheduler_leader: bool = True
|
||||
|
||||
# Seed demo data (projects/tools/auth) on first run. Off => start from an empty
|
||||
# workspace and create projects yourself. Set FORGE_SEED_DEMO=true to populate.
|
||||
seed_demo: bool = False
|
||||
|
||||
# --- Versioning (entity change history) ---
|
||||
# How many recent versions to retain per entity (workflows, agents, tools, components,
|
||||
# auth providers, knowledge sources, project settings). Older versions are pruned on each
|
||||
# new snapshot. 0 = keep all (no pruning). Overridable per-tenant via tenant.settings
|
||||
# ("version_history_limit"); exposed in the console Settings > Versioning panel.
|
||||
version_history_limit: int = 5
|
||||
|
||||
# --- CORS ---
|
||||
cors_origins: Annotated[list[str], NoDecode] = ["http://localhost:3000", "http://127.0.0.1:3000"]
|
||||
|
||||
# --- Egress / SSRF guard (applies to REST/GraphQL tools, webhooks, web_fetch,
|
||||
# URL ingestion, and auth/OAuth token fetches). block_private rejects URLs that
|
||||
# resolve to private/loopback/link-local/metadata addresses. allow/deny host
|
||||
# lists match a host or any parent domain (e.g. "example.com" covers
|
||||
# "api.example.com"). Per-project overrides live in project.config.egress. ---
|
||||
egress_block_private: bool = True
|
||||
egress_allow_hosts: Annotated[list[str], NoDecode] = []
|
||||
egress_deny_hosts: Annotated[list[str], NoDecode] = []
|
||||
# Hosts permitted to resolve to a PRIVATE / loopback / link-local address even while
|
||||
# block_private is on (default-deny, explicit-allow). Use for trusted internal targets -
|
||||
# localhost during dev, an internal service, an on-prem host - WITHOUT disabling the SSRF
|
||||
# guard globally (so the app still boots in production). Matches a host or any parent
|
||||
# domain. Per-project override: project.config.egress.allow_private_hosts.
|
||||
egress_allow_private_hosts: Annotated[list[str], NoDecode] = []
|
||||
|
||||
# --- Rate limits / quotas (per tenant). 0 = unlimited. Per-tenant overrides may
|
||||
# live in tenant.settings (max_runs_per_minute / max_runs_per_day). ---
|
||||
run_rate_limit_per_minute: int = 60
|
||||
api_rate_limit_per_minute: int = 240
|
||||
# Ceiling on the UNAUTHENTICATED auth endpoints (login/register/refresh/accept-invite) -
|
||||
# brute-force / credential-stuffing guard (audit: login had no throttle). This is the STRICT
|
||||
# per-EMAIL rate (per-account guard); the per-IP bucket is 10x looser (stuffing/DoS across
|
||||
# many accounts). 0 = unlimited.
|
||||
auth_rate_limit_per_minute: int = 10
|
||||
# Wire api_rate_limit_per_minute as a global per-IP request ceiling (ASGI middleware).
|
||||
# Health/readiness/metrics and SSE stream paths are exempt. Set false to disable the
|
||||
# global guard (per-surface limits - runs/embed/auth/tools - still apply).
|
||||
enable_global_rate_limit: bool = True
|
||||
# Projected per-run cost (USD) reserved against the daily cost cap while a run is in flight,
|
||||
# so N concurrent runs can't each pass a stale "already-spent" check and blow past the cap.
|
||||
# 0 = disabled (admit on completed-cost only, prior behavior). Per-tenant override:
|
||||
# tenant.settings["projected_run_cost_usd"] / ["max_cost_per_run_usd"].
|
||||
projected_run_cost_usd: float = 0.0
|
||||
# Timezone for the daily quota reset window (was hard-coded to UTC midnight). Per-tenant
|
||||
# override: tenant.settings["reset_tz"]. Falls back to UTC where tzdata is unavailable.
|
||||
quota_reset_tz: str = "UTC"
|
||||
# Bounded concurrency for evaluation runs (each dataset item is a full billable run).
|
||||
eval_concurrency: int = 5
|
||||
|
||||
# --- Public embed surface (anonymous, browser-facing). The publishable key is PUBLIC
|
||||
# by design, so these are the real abuse/cost ceilings. Per-IP is the important one
|
||||
# (a single key is shared by every visitor). 0 = unlimited. The daily tenant quota
|
||||
# (above) is ALSO enforced on the embed path. ---
|
||||
embed_rate_limit_per_minute: int = 60 # per publishable key
|
||||
embed_rate_limit_per_ip_per_minute: int = 20 # per client IP (denial-of-wallet guard)
|
||||
embed_stream_limit_per_ip_per_minute: int = 60 # SSE connections per IP
|
||||
|
||||
# Max concurrent in-flight runs per tenant (0 = unlimited). Backpressure / noisy-
|
||||
# neighbour guard for the inline execution path until the worker tier is enabled.
|
||||
max_concurrent_runs_per_tenant: int = 20
|
||||
|
||||
# How many recent turns (Trace rows) the Traces conversation list scans before grouping
|
||||
# by thread in Python. Bounds the query regardless of retention; raise it for projects
|
||||
# with very deep history at the cost of a wider scan.
|
||||
conversation_scan_limit: int = 2000
|
||||
|
||||
# --- Data retention (scheduled purge; leader-only, wired into the reaper loop). Traces/
|
||||
# spans/runs are purged per-project by project.config.tracing.retention_days; audit logs by
|
||||
# audit_log_retention_days. 0 anywhere = keep forever (no purge). ---
|
||||
enable_retention: bool = True
|
||||
retention_interval_seconds: int = 3600 # how often the purge sweep runs
|
||||
audit_log_retention_days: int = 0 # workspace-wide audit-log floor; 0 = keep forever
|
||||
# Fallback trace/span/run retention (days) for projects that don't set
|
||||
# tracing.retention_days in their config. 0 = keep forever.
|
||||
default_trace_retention_days: int = 0
|
||||
|
||||
# Best-effort worker count (mirror your gunicorn/uvicorn --workers). Only used so the
|
||||
# startup guard can WARN when >1 worker runs WITHOUT Redis - in-process rate-limit /
|
||||
# idempotency / token-revocation state is per-worker and won't be shared. Also read from
|
||||
# the conventional $WEB_CONCURRENCY env var.
|
||||
web_concurrency: int = 1
|
||||
|
||||
# Reverse-proxy IPs whose X-Forwarded-For we trust for client-IP derivation. Empty =>
|
||||
# trust none (use the socket peer). Set to your LB/ingress IPs in production so clients
|
||||
# can't spoof their IP for per-IP rate limits / audit. "*" trusts any (only behind a
|
||||
# trusted ingress that always overwrites XFF).
|
||||
trusted_proxies: Annotated[list[str], NoDecode] = []
|
||||
|
||||
# Host allow-list for the API (TrustedHostMiddleware). Empty => allow any (dev).
|
||||
trusted_hosts: Annotated[list[str], NoDecode] = []
|
||||
|
||||
# --- LangGraph checkpointer backend: "sqlite" (default, dev), "memory" (ephemeral),
|
||||
# or "postgres" (durable, shared across workers - required for prod/HITL). When
|
||||
# "postgres", set FORGE_CHECKPOINT_POSTGRES_URL (or it falls back to database_url). ---
|
||||
checkpoint_backend: str = "sqlite"
|
||||
checkpoint_postgres_url: str | None = None
|
||||
|
||||
@property
|
||||
def data_dir(self) -> Path:
|
||||
return _DEFAULT_DATA_DIR
|
||||
|
||||
def ensure_dirs(self) -> None:
|
||||
_DEFAULT_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
Path(self.chroma_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Environments treated as local/insecure-OK. ANY other value (staging, prod, an
|
||||
# unknown string, or the empty string) is treated as security-enforced - so a
|
||||
# misconfigured/typo'd FORGE_ENVIRONMENT fails CLOSED rather than silently skipping
|
||||
# every guard.
|
||||
_DEV_ENVIRONMENTS = ("development", "dev", "local", "test")
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.environment.lower() in ("production", "prod")
|
||||
|
||||
@property
|
||||
def enforce_security(self) -> bool:
|
||||
"""True when the deployment must pass the hardening checks. Only the explicit
|
||||
local-dev environment names opt out; everything else fails closed."""
|
||||
return self.environment.lower() not in self._DEV_ENVIRONMENTS
|
||||
|
||||
def validate_production(self) -> list[str]:
|
||||
"""Return a list of FATAL misconfigurations. Called at startup; an install with
|
||||
any of these should refuse to serve. Enforced for every non-dev environment
|
||||
(fail-closed), not just literal 'production'."""
|
||||
problems: list[str] = []
|
||||
if not self.enforce_security:
|
||||
return problems
|
||||
if self.jwt_secret in ("", "dev-insecure-change-me"):
|
||||
problems.append("FORGE_JWT_SECRET is unset/default - set a strong random secret.")
|
||||
if not self.auth_required:
|
||||
problems.append("FORGE_AUTH_REQUIRED must be true outside local development.")
|
||||
if self.bootstrap_admin_password == "forge-admin":
|
||||
problems.append("FORGE_BOOTSTRAP_ADMIN_PASSWORD is the dev default - set a real one.")
|
||||
if not self.egress_block_private:
|
||||
problems.append("FORGE_EGRESS_BLOCK_PRIVATE must stay true outside dev (SSRF guard).")
|
||||
if self.database_url.startswith("sqlite"):
|
||||
problems.append("SQLite is not supported outside dev - set a Postgres FORGE_DATABASE_URL.")
|
||||
if self.checkpoint_backend not in ("postgres",):
|
||||
problems.append(
|
||||
"FORGE_CHECKPOINT_BACKEND must be 'postgres' outside dev - a sqlite/memory "
|
||||
"checkpointer loses run/HITL state on restart and can't be shared across workers."
|
||||
)
|
||||
if self.enable_code_tools and not self.allow_unsandboxed_code_tools:
|
||||
problems.append(
|
||||
"FORGE_ENABLE_CODE_TOOLS is on but code execution is not OS-isolated. Disable it, "
|
||||
"or set FORGE_ALLOW_UNSANDBOXED_CODE_TOOLS=true to explicitly accept the RCE/DoS risk."
|
||||
)
|
||||
# A Host-header allow-list must be set outside dev (empty => TrustedHostMiddleware is
|
||||
# not even added, so Host/absolute-URI spoofing is unmitigated - audit S6/host attacks).
|
||||
if not self.trusted_hosts:
|
||||
problems.append("FORGE_TRUSTED_HOSTS must list the API's public hostname(s) outside dev.")
|
||||
# Public URLs are embedded in OAuth redirect URIs and emailed invite/reset links; they
|
||||
# MUST be https in production so tokens/codes never traverse cleartext.
|
||||
for name, url in (("FORGE_PUBLIC_BASE_URL", self.public_base_url),
|
||||
("FORGE_PUBLIC_CONSOLE_URL", self.public_console_url)):
|
||||
if not url.lower().startswith("https://"):
|
||||
problems.append(f"{name} must be an https:// URL outside dev (got {url!r}).")
|
||||
# A short service token is brute-forceable; if enabled at all it must be long+random.
|
||||
if self.service_api_token and len(self.service_api_token) < _MIN_SERVICE_TOKEN_LEN:
|
||||
problems.append(
|
||||
f"FORGE_SERVICE_API_TOKEN is set but shorter than {_MIN_SERVICE_TOKEN_LEN} chars - "
|
||||
"use a long random secret or leave it empty to disable server-to-server auth."
|
||||
)
|
||||
return problems
|
||||
|
||||
def _multi_worker_without_redis(self) -> bool:
|
||||
import os
|
||||
|
||||
workers = self.web_concurrency
|
||||
try:
|
||||
workers = max(workers, int(os.getenv("WEB_CONCURRENCY", "0") or 0))
|
||||
except ValueError:
|
||||
pass
|
||||
return workers > 1 and not self.redis_url
|
||||
|
||||
def startup_warnings(self) -> list[str]:
|
||||
"""Non-fatal but dangerous configuration, logged loudly at startup regardless of
|
||||
environment so an insecure local default is never silently shipped."""
|
||||
warns: list[str] = []
|
||||
if self.jwt_secret == "dev-insecure-change-me":
|
||||
warns.append("JWT secret is the built-in dev default - tokens are forgeable. Set FORGE_JWT_SECRET.")
|
||||
if self.bootstrap_admin_password == "forge-admin":
|
||||
warns.append("Bootstrap admin password is the dev default. Set FORGE_BOOTSTRAP_ADMIN_PASSWORD.")
|
||||
if not self.auth_required:
|
||||
warns.append("auth_required is false - unauthenticated requests act as the workspace owner.")
|
||||
if self.enable_code_tools:
|
||||
warns.append("Code tools are enabled and run unsandboxed (RestrictedPython only).")
|
||||
if self.enable_mcp_stdio:
|
||||
warns.append(
|
||||
"MCP stdio transport is enabled - external MCP clients can launch local processes "
|
||||
"(arbitrary command execution). Restrict FORGE_MCP_STDIO_ALLOWED_COMMANDS."
|
||||
)
|
||||
if self.environment.lower() not in (*self._DEV_ENVIRONMENTS, "production", "prod", "staging"):
|
||||
warns.append(f"Unrecognized FORGE_ENVIRONMENT={self.environment!r} - treated as security-enforced.")
|
||||
if self._multi_worker_without_redis():
|
||||
warns.append(
|
||||
"Multiple workers configured without FORGE_REDIS_URL - rate limits, idempotency, "
|
||||
"and token revocation are in-process (per-worker) and won't be shared/enforced "
|
||||
"globally. Set FORGE_REDIS_URL for multi-worker deployments."
|
||||
)
|
||||
return warns
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
s = Settings()
|
||||
s.ensure_dirs()
|
||||
return s
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Async SQLAlchemy persistence (SQLite default; Postgres via DATABASE_URL swap)."""
|
||||
|
||||
from forge.db.base import Base, SessionLocal, engine, get_session, init_db
|
||||
|
||||
__all__ = ["Base", "SessionLocal", "engine", "get_session", "init_db"]
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Async engine, session factory, and declarative base.
|
||||
|
||||
SQLite (aiosqlite) by default; set FORGE_DATABASE_URL to a Postgres async URL in
|
||||
prod (no code change). `init_db` creates tables for dev; Alembic owns prod migrations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from forge.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class PkTimestamp:
|
||||
"""Mixin: string-UUID primary key + created/updated timestamps."""
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
# SQLite needs check_same_thread off for the async driver's connection sharing.
|
||||
_is_sqlite = settings.database_url.startswith("sqlite")
|
||||
_connect_args = {"check_same_thread": False} if _is_sqlite else {}
|
||||
# SQLite (dev/test, via aiosqlite): pooling connections across asyncio event loops - e.g. the
|
||||
# per-test loops pytest-asyncio spins up - leaves a connection to be torn down in a loop other
|
||||
# than the one that opened it, causing intermittent "Task was destroyed but it is pending" /
|
||||
# "object NoneType can't be used in 'await'" teardown errors. NullPool opens a fresh connection
|
||||
# per checkout (cheap for a local sqlite file) and closes it immediately, so nothing lingers
|
||||
# across loops. Postgres (prod) keeps the default pooled behaviour - no perf change there.
|
||||
_engine_kwargs = {"poolclass": NullPool} if _is_sqlite else {}
|
||||
engine = create_async_engine(
|
||||
settings.database_url, echo=False, future=True, connect_args=_connect_args, **_engine_kwargs
|
||||
)
|
||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||
|
||||
|
||||
# --- Postgres Row-Level Security wiring --------------------------------------------------
|
||||
# infra/postgres_rls.sql policies filter on current_setting('app.current_tenant'). Nothing set
|
||||
# that GUC before, so applying the (FORCE) RLS policies returned ZERO rows and broke the app.
|
||||
# Set it per-transaction from the request-scoped tenant contextvar (forge.db.scoping) via
|
||||
# set_config(..., is_local=true) so it auto-resets at commit/rollback. Postgres-only: SQLite
|
||||
# (dev/test) has no RLS, so this listener isn't attached there and the suite is unaffected.
|
||||
# Defensive: a failure to set the GUC must never break the transaction.
|
||||
if not _is_sqlite:
|
||||
from sqlalchemy import event, text
|
||||
|
||||
@event.listens_for(engine.sync_engine, "begin")
|
||||
def _apply_tenant_guc(conn): # pragma: no cover - only exercised against Postgres
|
||||
try:
|
||||
from forge.db.scoping import current_tenant
|
||||
|
||||
tid = current_tenant()
|
||||
if tid:
|
||||
conn.execute(text("SELECT set_config('app.current_tenant', :tid, true)"), {"tid": str(tid)})
|
||||
except Exception: # noqa: BLE001 - RLS GUC is best-effort; never break the txn
|
||||
pass
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
# Import models so they register on Base.metadata before create_all.
|
||||
from forge import models # noqa: F401
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
await conn.run_sync(_ensure_new_columns)
|
||||
|
||||
|
||||
def _ensure_new_columns(conn) -> None:
|
||||
"""Dev-grade additive migration: create_all never alters existing tables, so
|
||||
columns added to the ORM after a table exists must be ALTERed in. Alembic owns
|
||||
real migrations in prod; this covers the SQLite dev database."""
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
wanted = {
|
||||
"kb_sources": {"folder": "VARCHAR(200) NOT NULL DEFAULT ''"},
|
||||
"triggers": {"metadata": "JSON DEFAULT '{}'"},
|
||||
"agents": {"created_by": "VARCHAR(36)", "created_by_email": "VARCHAR(320)"},
|
||||
"mcp_clients": {"disabled_tools": "JSON DEFAULT '[]'"},
|
||||
"api_keys": {"user_id": "VARCHAR(36)", "project_id": "VARCHAR(36)"},
|
||||
"tool_sets": {"exposed": "BOOLEAN NOT NULL DEFAULT 1"},
|
||||
"projects": {"embed_key": "VARCHAR(64)"},
|
||||
"spans": {"input": "JSON", "output": "JSON"},
|
||||
"runs": {"source": "VARCHAR(40) NOT NULL DEFAULT 'playground'"},
|
||||
"traces": {
|
||||
"source": "VARCHAR(40) NOT NULL DEFAULT 'playground'",
|
||||
"actor": "VARCHAR(300) NOT NULL DEFAULT 'System'",
|
||||
"end_user_id": "VARCHAR(200)",
|
||||
"user_message": "TEXT",
|
||||
"ai_response": "TEXT",
|
||||
},
|
||||
}
|
||||
inspector = inspect(conn)
|
||||
for table, columns in wanted.items():
|
||||
if table not in inspector.get_table_names():
|
||||
continue
|
||||
existing = {c["name"] for c in inspector.get_columns(table)}
|
||||
for col, ddl in columns.items():
|
||||
if col not in existing:
|
||||
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}"))
|
||||
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tenant-scoping helpers - one place to enforce row-level tenant isolation.
|
||||
|
||||
Every query against a tenant-scoped table should go through `tenant_scoped` so the
|
||||
`tenant_id` filter can never be forgotten. On Postgres this is backed up by Row-Level
|
||||
Security (see `infra/postgres_rls.sql`), which is a DB-level guarantee even if a query
|
||||
slips through; SQLite (dev) relies on this query-level scoping alone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import TypeVar
|
||||
|
||||
from sqlalchemy import Select
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def tenant_scoped(stmt: Select, model, tenant_id: str, *, project_id: str | None = None) -> Select:
|
||||
"""Add `WHERE tenant_id = :tenant_id` (and optional project_id) to a select."""
|
||||
stmt = stmt.where(model.tenant_id == tenant_id)
|
||||
if project_id is not None and hasattr(model, "project_id"):
|
||||
stmt = stmt.where(model.project_id == project_id)
|
||||
return stmt
|
||||
|
||||
|
||||
# Request-scoped tenant id, read by the Postgres RLS GUC listener (see forge.db.base) so the
|
||||
# `app.current_tenant` setting is populated on every transaction and infra/postgres_rls.sql
|
||||
# policies actually filter rows. Set by the `current_tenant_id` dependency for authenticated
|
||||
# routes and by `tenant_guard(...)` around non-request work (runs, dispatch, scheduler). It is
|
||||
# defense-in-depth ON TOP OF the explicit `WHERE tenant_id=` scoping - never the only guard.
|
||||
_current_tenant: ContextVar[str | None] = ContextVar("forge_current_tenant", default=None)
|
||||
|
||||
|
||||
def set_current_tenant(tenant_id: str | None):
|
||||
return _current_tenant.set(tenant_id)
|
||||
|
||||
|
||||
def current_tenant() -> str | None:
|
||||
return _current_tenant.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def tenant_guard(tenant_id: str | None):
|
||||
"""Bind the current tenant for the duration of a block (non-request code paths: run
|
||||
execution, trigger dispatch, scheduler). Resets on exit so it can't leak across tasks."""
|
||||
token = _current_tenant.set(tenant_id)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_current_tenant.reset(token)
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Bootstrap + optional demo seed.
|
||||
|
||||
`bootstrap` always ensures a single tenant (+ owner user) exists so the app has a
|
||||
tenant context - but creates NO projects, so you start from an empty workspace and
|
||||
build from scratch in the UI.
|
||||
|
||||
`seed_demo_data` (only when FORGE_SEED_DEMO=true) populates the showcase project,
|
||||
tools, auth provider, and a runnable workflow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from forge.models import AuthProvider, Project, Tenant, Tool, User, Workflow
|
||||
from forge.secrets.store import SecretStore
|
||||
|
||||
SEED_EXECUTABLE: dict = {
|
||||
"id": "support_router",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"messages": {"type": "list[message]", "reducer": "add_messages"},
|
||||
"intent": {"type": "str", "reducer": "last"},
|
||||
},
|
||||
"entry_node": "start",
|
||||
"global_middleware": [{"type": "model_call_limit", "config": {"run_limit": 25}}],
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "config": {}, "position": {"x": 40, "y": 200}},
|
||||
{
|
||||
"id": "intent_router",
|
||||
"type": "router",
|
||||
"config": {
|
||||
"expression": "intent",
|
||||
"cases": {"billing": "billing_agent", "technical": "tech_agent"},
|
||||
"default": "billing_agent",
|
||||
},
|
||||
"position": {"x": 300, "y": 200},
|
||||
},
|
||||
{
|
||||
"id": "billing_agent",
|
||||
"type": "agent",
|
||||
"config": {
|
||||
"flavor": "agent",
|
||||
"name": "billing_agent",
|
||||
"model": "fake:Thanks - your billing question is resolved.",
|
||||
"system_prompt": "You are the billing support agent. Be concise and helpful.",
|
||||
"middleware": [
|
||||
{"type": "summarization", "config": {"trigger": ["tokens", 4000]}},
|
||||
{"type": "tool_call_limit", "config": {"run_limit": 3}},
|
||||
],
|
||||
},
|
||||
"position": {"x": 600, "y": 110},
|
||||
},
|
||||
{
|
||||
"id": "tech_agent",
|
||||
"type": "agent",
|
||||
"config": {
|
||||
"flavor": "agent",
|
||||
"name": "tech_agent",
|
||||
"model": "fake:Here's how to fix your technical issue.",
|
||||
"system_prompt": "You are the technical support agent.",
|
||||
},
|
||||
"position": {"x": 600, "y": 300},
|
||||
},
|
||||
{"id": "end", "type": "end", "config": {}, "position": {"x": 900, "y": 200}},
|
||||
],
|
||||
"edges": [
|
||||
{"source": "start", "target": "intent_router"},
|
||||
{"source": "billing_agent", "target": "end"},
|
||||
{"source": "tech_agent", "target": "end"},
|
||||
],
|
||||
}
|
||||
|
||||
SEED_CANVAS: dict = {
|
||||
"nodes": [
|
||||
{"id": n["id"], "type": n["type"], "position": n.get("position", {"x": 0, "y": 0}), "data": {}}
|
||||
for n in SEED_EXECUTABLE["nodes"]
|
||||
],
|
||||
"edges": [
|
||||
{"id": f"e{i}", "source": e["source"], "target": e["target"]}
|
||||
for i, e in enumerate(SEED_EXECUTABLE["edges"])
|
||||
],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||
}
|
||||
|
||||
_DEMO_PROJECTS = [
|
||||
("Customer Support", "customer-support", "active", {"workflows": 1, "tools": 3, "runs7d": 1840}),
|
||||
("Internal Ops Bot", "internal-ops-bot", "active", {"workflows": 0, "tools": 0, "runs7d": 620}),
|
||||
("Sales Assistant", "sales-assistant", "active", {"workflows": 0, "tools": 0, "runs7d": 980}),
|
||||
]
|
||||
|
||||
|
||||
async def bootstrap(session) -> str:
|
||||
"""Ensure exactly one tenant (+ owner user). Returns the tenant id. No projects.
|
||||
|
||||
The owner's password comes from FORGE_BOOTSTRAP_ADMIN_PASSWORD when set, so the
|
||||
first login works once auth is enabled; otherwise the owner has no password and
|
||||
is only usable via the no-auth dev fallback (or self-service register)."""
|
||||
from forge.config import settings
|
||||
from forge.security import hash_password
|
||||
|
||||
existing = (await session.execute(select(Tenant))).scalars().first()
|
||||
if existing:
|
||||
# Backfill a password on the seeded owner if one is now configured.
|
||||
if settings.bootstrap_admin_password:
|
||||
owner = (
|
||||
await session.execute(
|
||||
select(User).where(User.tenant_id == existing.id, User.role == "owner")
|
||||
)
|
||||
).scalars().first()
|
||||
if owner and not owner.password_hash:
|
||||
owner.password_hash = hash_password(settings.bootstrap_admin_password)
|
||||
await session.commit()
|
||||
return existing.id
|
||||
tenant = Tenant(name="My Workspace", plan="free")
|
||||
session.add(tenant)
|
||||
await session.flush()
|
||||
session.add(User(
|
||||
tenant_id=tenant.id, email=settings.bootstrap_admin_email, role="owner",
|
||||
password_hash=hash_password(settings.bootstrap_admin_password) if settings.bootstrap_admin_password else None,
|
||||
))
|
||||
await session.commit()
|
||||
return tenant.id
|
||||
|
||||
|
||||
async def seed_demo_data(session, tenant_id: str) -> None:
|
||||
"""Populate showcase projects/tools/auth/workflow. Idempotent (skips if any project exists)."""
|
||||
has_project = (
|
||||
await session.execute(select(Project).where(Project.tenant_id == tenant_id))
|
||||
).scalars().first()
|
||||
if has_project:
|
||||
return
|
||||
|
||||
first_project_id = None
|
||||
for name, slug, status, stats in _DEMO_PROJECTS:
|
||||
project = Project(
|
||||
tenant_id=tenant_id, name=name, slug=slug, description=f"{name} agents and workflows.",
|
||||
status=status, config={"default_model": "fake:echo", "stats": stats},
|
||||
)
|
||||
session.add(project)
|
||||
await session.flush()
|
||||
if first_project_id is None:
|
||||
first_project_id = project.id
|
||||
|
||||
session.add(Workflow(
|
||||
tenant_id=tenant_id, project_id=first_project_id, name="Support Router",
|
||||
description="Intent router → billing/tech agents.",
|
||||
executable=SEED_EXECUTABLE, canvas=SEED_CANVAS, status="active",
|
||||
))
|
||||
|
||||
await SecretStore().write(
|
||||
session, tenant_id=tenant_id, project_id=first_project_id,
|
||||
name="orders_api_creds", kind="csrf_session",
|
||||
value={"username": "svc_acme", "password": "s3cr3t"},
|
||||
)
|
||||
ap = AuthProvider(
|
||||
tenant_id=tenant_id, project_id=first_project_id, name="orders_session", kind="csrf_session",
|
||||
credentials_ref="secret://proj/orders_api_creds",
|
||||
config={
|
||||
"kind": "csrf_session", "credentials_ref": "secret://proj/orders_api_creds",
|
||||
"token_fetch": {
|
||||
"method": "POST", "url": "https://api.acme.dev/auth/login",
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"body": {"username": "{{cred.username}}", "password": "{{cred.password}}"},
|
||||
},
|
||||
"extract": [
|
||||
{"name": "csrf", "from": "header", "header": "X-CSRF-Token"},
|
||||
{"name": "session", "from": "cookie", "cookie": "SESSIONID"},
|
||||
],
|
||||
"inject": [
|
||||
{"to": "header", "name": "X-CSRF-Token", "value": "{{extracted.csrf}}"},
|
||||
{"to": "cookie", "name": "SESSIONID", "value": "{{extracted.session}}"},
|
||||
],
|
||||
"cache_ttl_seconds": 1800, "refresh_on": [401, 403],
|
||||
},
|
||||
)
|
||||
session.add(ap)
|
||||
await session.flush()
|
||||
|
||||
session.add(Tool(
|
||||
tenant_id=tenant_id, project_id=first_project_id, name="get_order", kind="rest_api",
|
||||
auth_provider_id=ap.id, last_tested="pass",
|
||||
config={
|
||||
"description": "Fetch an order by ID, including line items and totals.",
|
||||
"request": {
|
||||
"method": "GET", "url_template": "https://api.acme.dev/v2/orders/{order_id}",
|
||||
"fields": [
|
||||
{"path": "order_id", "type": "string", "in": "path", "required": True, "llm_visible": True, "description": "The order identifier"},
|
||||
{"path": "include", "type": "string", "in": "query", "required": False, "llm_visible": False, "default": "totals,customer"},
|
||||
],
|
||||
"headers": [{"name": "Accept", "value": "application/json"}],
|
||||
},
|
||||
"response": {"projection_jmespath": "data.{subtotal: totals.subtotal, total: totals.grand_total, status: status}"},
|
||||
"timeout_seconds": 30,
|
||||
},
|
||||
))
|
||||
session.add(Tool(tenant_id=tenant_id, project_id=first_project_id, name="current_time", kind="builtin", last_tested="pass", config={"builtin": "current_time", "description": "Get the current UTC time."}))
|
||||
session.add(Tool(tenant_id=tenant_id, project_id=first_project_id, name="calculator", kind="builtin", last_tested="pass", config={"builtin": "calculator", "description": "Evaluate an arithmetic expression."}))
|
||||
|
||||
await session.commit()
|
||||
@@ -0,0 +1,206 @@
|
||||
"""FastAPI dependencies: session, auth/tenant resolution, RBAC.
|
||||
|
||||
Auth rollout is gated by `settings.auth_required`:
|
||||
- True → every request must carry a valid `Authorization: Bearer <access-token>`;
|
||||
the user is loaded from the DB and must be `active`.
|
||||
- False → requests with no token fall back to the seeded workspace owner so the
|
||||
console keeps working during the migration (dev default).
|
||||
|
||||
`current_tenant_id` is derived from the resolved user, so every existing route that
|
||||
already depends on it becomes tenant-scoped automatically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.config import settings
|
||||
from forge.db import SessionLocal
|
||||
from forge.security import TokenError, decode_token, tokens_revoked_after
|
||||
from forge.services.auth import AuthService, role_at_least
|
||||
from forge.services.runs import RunService
|
||||
from forge.util.clientip import resolve_client_ip
|
||||
|
||||
|
||||
@dataclass
|
||||
class CurrentUser:
|
||||
id: str
|
||||
tenant_id: str
|
||||
role: str
|
||||
email: str | None = None
|
||||
is_fallback: bool = False
|
||||
|
||||
|
||||
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def _bearer(request: Request) -> str | None:
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization")
|
||||
if not auth:
|
||||
return None
|
||||
parts = auth.split(None, 1)
|
||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||
return parts[1].strip()
|
||||
return None
|
||||
|
||||
|
||||
async def get_current_user(request: Request) -> CurrentUser:
|
||||
token = _bearer(request)
|
||||
if token:
|
||||
# Static service token (trusted server-to-server integrations): a fixed shared secret
|
||||
# that authenticates as a least-privilege service identity in the seeded workspace.
|
||||
# Checked before JWT decode (it isn't a JWT); constant-time compare to avoid leaking it.
|
||||
svc = settings.service_api_token
|
||||
if svc and hmac.compare_digest(token, svc):
|
||||
tenant_id = getattr(request.app.state, "tenant_id", None)
|
||||
if not tenant_id:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "service token: workspace not initialized")
|
||||
return CurrentUser(id="service", tenant_id=tenant_id, role="editor", email="service@forge.local")
|
||||
# Per-tenant, role-scoped, revocable API keys (finding h). Recognizable by prefix and
|
||||
# not a JWT, so resolve before the JWT decode. Identity is `apikey:<id>` in a specific
|
||||
# tenant with the key's assigned role.
|
||||
from forge.services.apikeys import ApiKeyService, looks_like_api_key
|
||||
|
||||
if looks_like_api_key(token):
|
||||
async with SessionLocal() as s:
|
||||
key = await ApiKeyService.resolve(s, token)
|
||||
if key is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid or revoked API key")
|
||||
return CurrentUser(id=f"apikey:{key.id}", tenant_id=key.tenant_id, role=key.role,
|
||||
email=f"apikey:{key.name}")
|
||||
try:
|
||||
claims = decode_token(token, expected_type="access")
|
||||
except TokenError as e:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"invalid token: {e}") from e
|
||||
# Logout-all / password-change cutoff: reject an access token minted before the user's
|
||||
# current revocation horizon even though it's otherwise still valid (finding d).
|
||||
if tokens_revoked_after(claims):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session has been signed out")
|
||||
async with SessionLocal() as s:
|
||||
user = await AuthService.get_user(s, claims.get("sub", ""))
|
||||
if user is None or user.status != "active":
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "account not found or disabled")
|
||||
return CurrentUser(id=user.id, tenant_id=user.tenant_id, role=user.role, email=user.email)
|
||||
|
||||
if settings.auth_required:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "authentication required")
|
||||
|
||||
# Backward-compatible fallback: the seeded workspace owner.
|
||||
tenant_id = getattr(request.app.state, "tenant_id", None)
|
||||
if not tenant_id:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "no authenticated user")
|
||||
return CurrentUser(id="system-dev", tenant_id=tenant_id, role="owner", email="you@forge.local", is_fallback=True)
|
||||
|
||||
|
||||
def current_tenant_id(user: CurrentUser = Depends(get_current_user)) -> str:
|
||||
# Bind the tenant for this request so the Postgres RLS GUC listener (forge.db.base) sets
|
||||
# app.current_tenant on every transaction that follows in the route body. No-op on SQLite.
|
||||
from forge.db.scoping import set_current_tenant
|
||||
|
||||
set_current_tenant(user.tenant_id)
|
||||
return user.tenant_id
|
||||
|
||||
|
||||
def _more_privileged(a: str, b: str) -> str:
|
||||
"""Return whichever of two roles carries MORE privilege (owner > admin > editor > viewer)."""
|
||||
return a if role_at_least(a, b) else b
|
||||
|
||||
|
||||
async def effective_role(user: CurrentUser, request: Request) -> str:
|
||||
"""The caller's role on the request's project: a per-project ProjectMember grant ELEVATES the
|
||||
tenant-wide role (never demotes it), so this is additive (finding h). Falls back to the global
|
||||
role when there is no project in the path, no membership row, or a non-user identity (service /
|
||||
API key / dev fallback)."""
|
||||
project_id = request.path_params.get("project_id")
|
||||
if not project_id or user.is_fallback or user.id.startswith(("apikey:", "service")):
|
||||
return user.role
|
||||
from forge.models.entities import ProjectMember
|
||||
|
||||
async with SessionLocal() as s:
|
||||
pm = (
|
||||
await s.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.tenant_id == user.tenant_id,
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user.id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return user.role if pm is None else _more_privileged(user.role, pm.role)
|
||||
|
||||
|
||||
def require_role(minimum: str):
|
||||
"""Dependency factory: require the caller's EFFECTIVE role to be at least `minimum`
|
||||
(owner > admin > editor > viewer). The effective role is the tenant-wide role, optionally
|
||||
elevated by a per-project ProjectMember grant for the request's {project_id} (finding h).
|
||||
Use on mutating/administrative routes."""
|
||||
|
||||
async def _dep(request: Request, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||
role = await effective_role(user, request)
|
||||
if not role_at_least(role, minimum):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, f"requires role '{minimum}' or higher")
|
||||
# Reflect the (possibly elevated) effective role back to the route body.
|
||||
if role != user.role:
|
||||
return CurrentUser(id=user.id, tenant_id=user.tenant_id, role=role,
|
||||
email=user.email, is_fallback=user.is_fallback)
|
||||
return user
|
||||
|
||||
return _dep
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str | None:
|
||||
"""Client IP for per-IP rate limits / audit. Believes X-Forwarded-For only when the socket
|
||||
peer is a configured reverse proxy (settings.trusted_proxies); see forge.util.clientip.
|
||||
Shared with the audit middleware so the two resolvers can't drift."""
|
||||
peer = request.client.host if request.client else None
|
||||
return resolve_client_ip(peer, request.headers.get("x-forwarded-for"), settings.trusted_proxies)
|
||||
|
||||
|
||||
# Header carrying ephemeral, per-run request context (a JSON object) that a server-side caller
|
||||
# passes on a run's EXECUTION request (stream/resume). Its values are exposed to tools as
|
||||
# {{ctx.<key>}} for on-behalf-of injection (e.g. a per-user session cookie / CSRF token) and
|
||||
# are NEVER persisted or placed in the LLM prompt. Keep it small - it is a credential/context
|
||||
# channel, not a data channel.
|
||||
FORGE_CONTEXT_HEADER = "x-forge-context"
|
||||
_MAX_RUN_CONTEXT_BYTES = 8192
|
||||
|
||||
|
||||
def run_context(request: Request) -> dict | None:
|
||||
"""Parse the `X-Forge-Context` header into a per-run context dict, or None if absent.
|
||||
|
||||
Rejects non-JSON / non-object / oversized payloads. `end_user` is stripped: run identity
|
||||
is asserted via the run body / session token, not this header, so it can't be spoofed here.
|
||||
"""
|
||||
raw = request.headers.get(FORGE_CONTEXT_HEADER)
|
||||
if not raw:
|
||||
return None
|
||||
if len(raw) > _MAX_RUN_CONTEXT_BYTES:
|
||||
raise HTTPException(413, f"{FORGE_CONTEXT_HEADER} header too large")
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{FORGE_CONTEXT_HEADER} must be a valid JSON object") from e
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"{FORGE_CONTEXT_HEADER} must be a JSON object")
|
||||
data.pop("end_user", None) # identity is not settable via this channel
|
||||
return data or None
|
||||
|
||||
|
||||
def get_run_service(request: Request) -> RunService:
|
||||
return RunService(
|
||||
checkpointer=getattr(request.app.state, "checkpointer", None),
|
||||
store=getattr(request.app.state, "store", None),
|
||||
)
|
||||
|
||||
|
||||
def get_checkpointer(request: Request):
|
||||
return getattr(request.app.state, "checkpointer", None)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Forge execution engine: JSON workflow definitions -> compiled LangGraph graphs.
|
||||
|
||||
The heart of the platform. Public surface:
|
||||
|
||||
- `registry` - Node Type Registry (`NodeSpec`, `Port`, `register`).
|
||||
- `compile_workflow` - executable JSON -> `CompiledStateGraph`.
|
||||
- `build_state_typeddict` - state schema dict -> runtime `TypedDict` with reducers.
|
||||
- `build_middleware` - middleware stack list -> `list[AgentMiddleware]`.
|
||||
- `resolve_model` - model ref string -> chat model (provider or fake).
|
||||
- `CompileContext` - per-compile dependencies (tenant, checkpointer, tools, ...).
|
||||
"""
|
||||
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.engine.models import resolve_model
|
||||
from forge.engine.registry import NODE_REGISTRY, NodeSpec, Port, io_compatible, register
|
||||
from forge.engine.state import build_state_typeddict
|
||||
|
||||
__all__ = [
|
||||
"CompileContext",
|
||||
"resolve_model",
|
||||
"NODE_REGISTRY",
|
||||
"NodeSpec",
|
||||
"Port",
|
||||
"io_compatible",
|
||||
"register",
|
||||
"build_state_typeddict",
|
||||
]
|
||||
@@ -0,0 +1,188 @@
|
||||
"""The workflow compiler (Doc 2 §6): executable JSON -> `CompiledStateGraph`.
|
||||
|
||||
Topologically agnostic - it trusts the validator (schemas/workflow.json + extra
|
||||
rules) to have already rejected bad definitions. Routing:
|
||||
|
||||
- `router` nodes route via their own `config.cases`/`default` (conditional edges);
|
||||
their labeled out-edges in `edges[]` are ignored.
|
||||
- edges with `branches` (value->target) become conditional edges keyed by an
|
||||
optional `condition` expression.
|
||||
- `end` nodes are wired to END; plain edges are added as-is.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
|
||||
import forge.nodes # noqa: F401 (import registers all built-in node types)
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.engine.expressions import ExpressionError, eval_expression
|
||||
from forge.engine.registry import get_spec
|
||||
from forge.engine.state import build_state_typeddict
|
||||
from forge.nodes.flow import (
|
||||
make_fanout_path,
|
||||
make_router_path,
|
||||
resilient_fanout_child,
|
||||
router_targets,
|
||||
)
|
||||
|
||||
log = logging.getLogger("forge.compiler")
|
||||
|
||||
|
||||
def _branch_path(condition: str | None, mapping: dict[str, str], source: str = "?"):
|
||||
def _path(state: dict) -> Any:
|
||||
if not condition:
|
||||
# A branches-edge with no condition can only ever fall through to END. The
|
||||
# validator now errors on this (audit F10c); if one still slips through, make the
|
||||
# dead-end visible rather than silently ending the run (mirrors flow.py routers).
|
||||
log.warning("edge from %r has branches but no condition; routing to END", source)
|
||||
return END
|
||||
try:
|
||||
val = str(eval_expression(condition, dict(state or {})))
|
||||
except ExpressionError as e:
|
||||
# A failing branch expression silently routing to END is a debugging nightmare;
|
||||
# log it (mirrors make_router_path in flow.py) so it's traceable (audit F8).
|
||||
log.warning("edge %r branch condition %r failed: %s", source, condition, e)
|
||||
return END
|
||||
if val in mapping:
|
||||
return mapping[val]
|
||||
log.warning("edge %r branch value %r matched no branch; routing to END", source, val)
|
||||
return END
|
||||
|
||||
return _path
|
||||
|
||||
|
||||
SUBAGENT_HANDLE = "subagents"
|
||||
|
||||
|
||||
def _subagent_spec_from_node(node: dict) -> dict:
|
||||
"""Turn a specialist agent NODE's config into the subagent dict `build_subagents` expects
|
||||
(name/description/system_prompt/tools/toolsets/model/middleware). Lets a deep_agent's
|
||||
sub-agents be authored as full agent nodes on the canvas rather than inline JSON."""
|
||||
cfg = node.get("config", {}) or {}
|
||||
name = cfg.get("name") or node["id"]
|
||||
spec: dict[str, Any] = {
|
||||
"name": name,
|
||||
# The supervisor reads this to decide when to call the sub-agent (like a tool description).
|
||||
"description": cfg.get("description") or f"The {name} specialist agent.",
|
||||
# deepagents requires every sub-agent to carry a system_prompt (it wraps it with a
|
||||
# profile prompt), so always provide one - fall back to the description / a generic.
|
||||
"system_prompt": cfg.get("system_prompt") or cfg.get("description") or f"You are the {name} specialist agent.",
|
||||
}
|
||||
for k in ("tools", "toolsets", "model", "middleware"):
|
||||
if cfg.get(k):
|
||||
spec[k] = cfg[k]
|
||||
return spec
|
||||
|
||||
|
||||
def compile_workflow(definition: dict, ctx: CompileContext):
|
||||
"""Compile an executable workflow definition into a runnable LangGraph graph."""
|
||||
state_schema = build_state_typeddict(definition.get("state", {}))
|
||||
builder = StateGraph(state_schema)
|
||||
|
||||
nodes = definition["nodes"]
|
||||
node_by_id = {n["id"]: n for n in nodes}
|
||||
|
||||
# Sub-agent edges (source_handle == "subagents") wire a deep_agent to specialist agent nodes
|
||||
# it can call as tools. Those child nodes are NOT standalone graph nodes: fold each child's
|
||||
# config into the parent deep_agent's `subagents` (create_deep_agent picks them up in
|
||||
# agent_factory) and skip the child + its edges below. This is the canvas supervisor pattern.
|
||||
subagent_child_ids: set[str] = set()
|
||||
subagent_by_parent: dict[str, list[str]] = {}
|
||||
for e in definition.get("edges", []):
|
||||
if e.get("source_handle") != SUBAGENT_HANDLE:
|
||||
continue
|
||||
parent, child = e["source"], e.get("target")
|
||||
if child in node_by_id and (node_by_id.get(parent) or {}).get("type") == "deep_agent":
|
||||
subagent_by_parent.setdefault(parent, []).append(child)
|
||||
subagent_child_ids.add(child)
|
||||
for parent_id, child_ids in subagent_by_parent.items():
|
||||
parent = node_by_id[parent_id]
|
||||
cfg = dict(parent.get("config") or {})
|
||||
cfg["subagents"] = list(cfg.get("subagents") or []) + [
|
||||
_subagent_spec_from_node(node_by_id[c]) for c in child_ids
|
||||
]
|
||||
parent["config"] = cfg
|
||||
|
||||
# A parallel_fanout dispatches one Send per item to its `child_node` (all run in one
|
||||
# superstep). Map each such child id -> its fanout config so we can optionally harden the
|
||||
# child against a single item's failure/timeout below (audit F2). The workflow-level
|
||||
# error_policy "continue" is the opt-in for partial-failure isolation.
|
||||
error_policy = definition.get("error_policy", "halt")
|
||||
fanout_children: dict[str, dict] = {}
|
||||
for n in nodes:
|
||||
if n["type"] == "parallel_fanout":
|
||||
fcfg = n.get("config", {}) or {}
|
||||
if fcfg.get("child_node"):
|
||||
fanout_children[fcfg["child_node"]] = fcfg
|
||||
|
||||
# 1) add every node from its registered factory (folded sub-agent children are not nodes)
|
||||
for n in nodes:
|
||||
if n["id"] in subagent_child_ids:
|
||||
continue
|
||||
spec = get_spec(n["type"])
|
||||
node_fn = spec.factory(n.get("config", {}) or {}, ctx)
|
||||
fcfg = fanout_children.get(n["id"])
|
||||
if fcfg is not None:
|
||||
# Isolate per-item errors when the workflow opts into continue-on-error OR the
|
||||
# fanout sets on_item_error="skip"; bound each item with an optional per-item
|
||||
# timeout. Default (halt / no timeout) leaves the child untouched (safe).
|
||||
isolate = error_policy == "continue" or fcfg.get("on_item_error") == "skip"
|
||||
timeout = fcfg.get("item_timeout_seconds")
|
||||
if isolate or timeout:
|
||||
node_fn = resilient_fanout_child(node_fn, timeout=timeout, isolate=isolate)
|
||||
builder.add_node(n["id"], node_fn)
|
||||
|
||||
# 2) terminal markers -> END
|
||||
for n in nodes:
|
||||
if n["type"] == "end":
|
||||
builder.add_edge(n["id"], END)
|
||||
|
||||
# 3) router nodes -> conditional edges from their own config
|
||||
routed: set[str] = set()
|
||||
for n in nodes:
|
||||
if n["type"] == "router":
|
||||
cfg = n.get("config", {}) or {}
|
||||
targets = router_targets(cfg) or [END]
|
||||
builder.add_conditional_edges(n["id"], make_router_path(cfg), targets)
|
||||
routed.add(n["id"])
|
||||
|
||||
# 3b) parallel_fanout nodes -> Send-based map to their child_node (skip normal edges)
|
||||
for n in nodes:
|
||||
if n["type"] == "parallel_fanout":
|
||||
cfg = n.get("config", {}) or {}
|
||||
child = cfg.get("child_node")
|
||||
if child:
|
||||
builder.add_conditional_edges(n["id"], make_fanout_path(cfg), [child])
|
||||
routed.add(n["id"])
|
||||
|
||||
# 4) explicit edges (skip self-routing router/fanout sources, sub-agent edges, and any edge
|
||||
# touching a folded sub-agent child - the child is no longer a node in the graph)
|
||||
for e in definition.get("edges", []):
|
||||
src = e["source"]
|
||||
if e.get("source_handle") == SUBAGENT_HANDLE:
|
||||
continue
|
||||
if src in routed:
|
||||
continue
|
||||
if src in subagent_child_ids or e.get("target") in subagent_child_ids:
|
||||
continue
|
||||
if e.get("branches"):
|
||||
mapping = {str(k): v for k, v in e["branches"].items()}
|
||||
# END must be in the target set: _branch_path falls through to END on a failed /
|
||||
# unmatched condition, and without END listed LangGraph raises KeyError('__end__')
|
||||
# at runtime instead of the intended (now-logged) graceful end (audit F8).
|
||||
targets = sorted(set(mapping.values()) | {END})
|
||||
builder.add_conditional_edges(
|
||||
src, _branch_path(e.get("condition"), mapping, src), targets
|
||||
)
|
||||
else:
|
||||
tgt = e["target"]
|
||||
builder.add_edge(src, END if tgt in ("END", "__end__") else tgt)
|
||||
|
||||
# 5) entry
|
||||
builder.add_edge(START, definition["entry_node"])
|
||||
|
||||
return builder.compile(checkpointer=ctx.checkpointer, store=ctx.store)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""CompileContext - the per-compile dependency bundle (Doc 2 §6).
|
||||
|
||||
Carries everything `NodeSpec.factory` / `MW_BUILDERS` need: tenant scoping, the
|
||||
checkpointer + store, the tracer callback, the materialized tool registry, the auth
|
||||
resolver, the sandbox, and model-provider credential bindings. Kept dependency-light
|
||||
(plain dataclass with optionals) so the engine core is unit-testable in isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompileContext:
|
||||
tenant_id: str
|
||||
project_id: str
|
||||
|
||||
# LangGraph durability + long-term memory.
|
||||
checkpointer: Any = None
|
||||
store: Any = None
|
||||
|
||||
# Tracing callback handler attached to every astream/ainvoke.
|
||||
tracer: Any = None
|
||||
|
||||
# Materialized tools: tool_id -> StructuredTool (built by tools.materialize).
|
||||
tool_registry: dict[str, Any] = field(default_factory=dict)
|
||||
# tool_id -> {"kind", "config", "tool"} so the tool_call node can invoke directly.
|
||||
tool_specs: dict[str, dict] = field(default_factory=dict)
|
||||
# LLM tool name (the underscore identifier the model calls) -> human-readable label
|
||||
# shown in streaming/chat activity. Populated for user tools (config.display_name) and
|
||||
# UI components (their title), each falling back to the identifier when unset. The model
|
||||
# never sees this - it only relabels tool_calls in the stream for end-user surfaces.
|
||||
tool_display_names: dict[str, str] = field(default_factory=dict)
|
||||
# MCP server id -> list of native LangChain tools (the server's enabled tools),
|
||||
# pre-loaded by the runtime assembler so the sync agent factory can attach them.
|
||||
mcp_tools_by_client: dict[str, list] = field(default_factory=dict)
|
||||
# Materialized UI components (component_id -> widget StructuredTool); attached to an
|
||||
# agent via config["components"], the same way tools are (Feature 2 - generative UI).
|
||||
component_registry: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Tool-set membership (tool_set_id -> [tool_id, ...]) for the project, populated by the
|
||||
# runtime assembler. Lets an agent be granted a whole set via config.toolsets and have it
|
||||
# resolve to the set's member tools at compile time (see resolve_tool_ids).
|
||||
toolset_members: dict[str, list[str]] = field(default_factory=dict)
|
||||
|
||||
# Cross-cutting services.
|
||||
auth_resolver: Any = None
|
||||
sandbox: Any = None
|
||||
|
||||
# SSRF egress policy (project override of the global allow/deny + private-range
|
||||
# block), applied to every outbound HTTP call a workflow makes (tools, webhooks,
|
||||
# web_fetch). Set by the runtime assembler from project.config.egress.
|
||||
egress_policy: Any = None
|
||||
|
||||
# Model config.
|
||||
default_model: str | None = None
|
||||
provider_credentials: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
# The end user this run acts for (identity, Feature 3). Generic app-defined shape
|
||||
# ({id, roles?, attributes?, entitlements?, …}); surfaced to agent prompts (awareness)
|
||||
# and tool templating ({{ctx.end_user…}} / on-behalf-of calls). None = anonymous.
|
||||
end_user: dict | None = None
|
||||
|
||||
# Ephemeral per-run request context (Feature: per-run context injection). Values a
|
||||
# server-side caller passes on the run's EXECUTION request (stream/resume, via the
|
||||
# `X-Forge-Context` header) for tools to inject into outbound calls as {{ctx.<key>}} -
|
||||
# e.g. a per-user session cookie / CSRF token when acting on the caller's behalf. UNLIKE
|
||||
# end_user this is NEVER persisted (not on the thread/run/checkpointer/trace) and NEVER
|
||||
# placed in the LLM prompt or an LLM-visible tool arg; it reaches only the tool's outbound
|
||||
# HTTP request and the auth resolver. Put per-request secrets HERE, not in end_user (which
|
||||
# is embedded in the prompt and stored on the thread).
|
||||
run_context: dict = field(default_factory=dict)
|
||||
|
||||
# Project-level default middleware, prepended to every agent stack (Doc 2 §8).
|
||||
project_default_mw: list[dict] = field(default_factory=list)
|
||||
|
||||
# Saved agent presets (agent_id -> config), so an agent node can mirror one by
|
||||
# `agent_ref` and pick up edits made in the Agents tab without re-saving the workflow.
|
||||
agent_presets: dict[str, dict] = field(default_factory=dict)
|
||||
|
||||
# Project workflows' executables (id -> definition) so a `subworkflow` node can
|
||||
# compile a referenced workflow as a nested graph. `compiling` tracks in-progress
|
||||
# ids to break recursion cycles.
|
||||
workflows: dict[str, dict] = field(default_factory=dict)
|
||||
compiling: set = field(default_factory=set)
|
||||
|
||||
def tools_for(self, ids: Sequence[str]) -> list[Any]:
|
||||
"""Resolve tool ids to materialized tools, skipping unknown ids.
|
||||
|
||||
Unknown ids are tolerated at compile time and surfaced by the validator
|
||||
instead, so a partially-wired draft still compiles for preview.
|
||||
"""
|
||||
out = []
|
||||
for i in ids or []:
|
||||
tool = self.tool_registry.get(i)
|
||||
if tool is not None:
|
||||
out.append(tool)
|
||||
return out
|
||||
|
||||
def resolve_tool_ids(self, tool_ids: Sequence[str] | None, set_ids: Sequence[str] | None = None) -> list[str]:
|
||||
"""Combine explicit tool ids with the members of any referenced tool sets into one
|
||||
order-stable, de-duplicated id list. Unknown set ids contribute nothing (tolerant,
|
||||
like tools_for), so an agent can be granted individual tools AND whole sets at once."""
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for tid in tool_ids or []:
|
||||
if tid not in seen:
|
||||
seen.add(tid)
|
||||
out.append(tid)
|
||||
for sid in set_ids or []:
|
||||
for tid in self.toolset_members.get(sid, []):
|
||||
if tid not in seen:
|
||||
seen.add(tid)
|
||||
out.append(tid)
|
||||
return out
|
||||
|
||||
def components_for(self, ids: Sequence[str]) -> list[Any]:
|
||||
"""Resolve component ids to materialized widget-tools, skipping unknown ids
|
||||
(a deleted component just drops out, like tools_for)."""
|
||||
out = []
|
||||
for i in ids or []:
|
||||
tool = self.component_registry.get(i)
|
||||
if tool is not None:
|
||||
out.append(tool)
|
||||
return out
|
||||
|
||||
def has_entitlements(self, required) -> bool:
|
||||
"""True if the run's end_user holds ALL of `required` (matched against roles ∪
|
||||
entitlements). Empty/absent requirement → allowed, anonymous user → denied. The
|
||||
server-side gate for tools that declare `required_entitlements` (Feature 3b)."""
|
||||
req = [r for r in (required or []) if r]
|
||||
if not req:
|
||||
return True
|
||||
eu = self.end_user or {}
|
||||
have = set(eu.get("entitlements") or []) | set(eu.get("roles") or [])
|
||||
return all(r in have for r in req)
|
||||
|
||||
def sandbox_backend_for(self, config: dict) -> Any:
|
||||
"""Deep-agent sandbox backend from a node's sandbox config. (Phase 3+.)"""
|
||||
return self.sandbox
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Safe expression evaluation over run state/context (Doc 4 `Expression`).
|
||||
|
||||
Forge uses TWO expression languages, by role - keep them straight:
|
||||
- **This one (RestrictedPython)** is for BOOLEAN/VALUE DECISIONS over state: `router`
|
||||
`expression`/`cases`, `loop` conditions, `dynamic_model_by_state`, `tenant_budget`.
|
||||
State keys are bare names, so `intent == 'billing'` and `len(messages) > 10` work.
|
||||
- **JMESPath** is for DATA EXTRACTION/RESHAPING: `transform` node, tool response
|
||||
`projection_jmespath`, and `tool_call` `input_mapping`. It selects/reshapes JSON; it
|
||||
does not evaluate Python comparisons.
|
||||
Rule of thumb: "which branch?" → this module; "pull/reshape these fields" → JMESPath.
|
||||
|
||||
Expressions here are sandboxed with RestrictedPython: no imports, no attribute escapes,
|
||||
only a small set of safe builtins, plus explicit `state` / `context` dicts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from RestrictedPython import compile_restricted, safe_builtins
|
||||
from RestrictedPython.Eval import default_guarded_getitem, default_guarded_getiter
|
||||
|
||||
_SAFE_NAMES: dict[str, Any] = {
|
||||
"len": len, "min": min, "max": max, "sum": sum, "abs": abs, "round": round,
|
||||
"any": any, "all": all, "sorted": sorted, "str": str, "int": int, "float": float,
|
||||
"bool": bool, "list": list, "dict": dict, "set": set, "tuple": tuple,
|
||||
"True": True, "False": False, "None": None,
|
||||
}
|
||||
|
||||
|
||||
class ExpressionError(ValueError):
|
||||
"""Raised when an expression fails to compile or evaluate."""
|
||||
|
||||
|
||||
def eval_expression(expr: str, state: dict | None = None, context: dict | None = None) -> Any:
|
||||
state = dict(state or {})
|
||||
context = dict(context or {})
|
||||
try:
|
||||
code = compile_restricted(expr, "<forge-expression>", "eval")
|
||||
except SyntaxError as e:
|
||||
raise ExpressionError(f"Invalid expression {expr!r}: {e}") from e
|
||||
|
||||
env: dict[str, Any] = {
|
||||
**state, # state keys as bare names - spread FIRST so a state key can't shadow a guard
|
||||
# Reserved/guard keys are set AFTER **state so the sandbox guards and safe builtins
|
||||
# always win (a state key named e.g. "__builtins__" or "_getitem_" can't override them).
|
||||
"__builtins__": safe_builtins,
|
||||
"_getitem_": default_guarded_getitem,
|
||||
"_getiter_": default_guarded_getiter,
|
||||
**_SAFE_NAMES,
|
||||
"state": state,
|
||||
"context": context,
|
||||
"ctx": context,
|
||||
}
|
||||
try:
|
||||
return eval(code, env, {}) # noqa: S307 - sandboxed by RestrictedPython
|
||||
except Exception as e: # noqa: BLE001 - surface any eval failure as ExpressionError
|
||||
raise ExpressionError(f"Failed to evaluate {expr!r}: {type(e).__name__}: {e}") from e
|
||||
|
||||
|
||||
def eval_truthy(expr: str, state: dict | None = None, context: dict | None = None) -> bool:
|
||||
return bool(eval_expression(expr, state, context))
|
||||
@@ -0,0 +1,601 @@
|
||||
"""Middleware-Stack Compiler (Doc 2 §8) - the engine of "limitless customization".
|
||||
|
||||
An agent node's `middleware: [{type, config, enabled}]` list compiles to a concrete
|
||||
`list[AgentMiddleware]`. Prebuilt builders wrap LangChain's catalog (signatures
|
||||
validated against langchain 1.3.4). The custom/advanced builders generate middleware
|
||||
from declarative rules so non-coders get power without writing code.
|
||||
|
||||
Add a middleware = add a `MW_BUILDERS` entry (+ a `config_schemas` entry in
|
||||
schemas/middleware.json + a `category-map` entry). It then appears everywhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Annotated, Any, NotRequired
|
||||
|
||||
from langchain.agents.middleware import (
|
||||
AgentMiddleware,
|
||||
AgentState,
|
||||
ClearToolUsesEdit,
|
||||
ContextEditingMiddleware,
|
||||
HumanInTheLoopMiddleware,
|
||||
LLMToolEmulator,
|
||||
LLMToolSelectorMiddleware,
|
||||
ModelCallLimitMiddleware,
|
||||
ModelFallbackMiddleware,
|
||||
ModelRetryMiddleware,
|
||||
PIIMiddleware,
|
||||
SummarizationMiddleware,
|
||||
TodoListMiddleware,
|
||||
ToolCallLimitMiddleware,
|
||||
ToolRetryMiddleware,
|
||||
hook_config,
|
||||
)
|
||||
from langchain.agents.middleware.types import PrivateStateAttr
|
||||
from langgraph.channels.untracked_value import UntrackedValue
|
||||
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.engine.expressions import eval_truthy
|
||||
from forge.engine.models import resolve_model
|
||||
|
||||
log = logging.getLogger("forge.middleware")
|
||||
|
||||
Builder = Callable[[dict, CompileContext], AgentMiddleware]
|
||||
|
||||
|
||||
# --- helpers ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _ctxsize(v: Any) -> Any:
|
||||
"""JSON ContextSize (list) -> tuple; list-of-ContextSize -> list[tuple]."""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, list) and v and isinstance(v[0], list):
|
||||
return [tuple(x) for x in v]
|
||||
if isinstance(v, (list, tuple)):
|
||||
return tuple(v)
|
||||
return v
|
||||
|
||||
|
||||
def _pick(c: dict, keys: list[str]) -> dict:
|
||||
return {k: c[k] for k in keys if k in c and c[k] is not None}
|
||||
|
||||
|
||||
def _context_matches(expose_when: dict, ctx_data: dict) -> bool:
|
||||
"""All keys in expose_when must match (value-in-list or equality)."""
|
||||
for k, want in (expose_when or {}).items():
|
||||
got = ctx_data.get(k)
|
||||
if isinstance(want, list):
|
||||
if got not in want:
|
||||
return False
|
||||
elif got != want:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _msg_text(msg: Any) -> str:
|
||||
content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", "")
|
||||
if isinstance(content, list):
|
||||
return " ".join(
|
||||
(b.get("text", "") if isinstance(b, dict) else str(b)) for b in content
|
||||
)
|
||||
return content or ""
|
||||
|
||||
|
||||
# --- prebuilt builders (signatures validated against langchain 1.3.4) ------
|
||||
|
||||
|
||||
def _summarization(c: dict, ctx: CompileContext) -> AgentMiddleware:
|
||||
kw: dict[str, Any] = {}
|
||||
if c.get("trigger") is not None:
|
||||
kw["trigger"] = _ctxsize(c["trigger"])
|
||||
if c.get("keep") is not None:
|
||||
kw["keep"] = _ctxsize(c["keep"])
|
||||
if c.get("summary_prompt"):
|
||||
kw["summary_prompt"] = c["summary_prompt"]
|
||||
return SummarizationMiddleware(model=resolve_model(c.get("model"), ctx), **kw)
|
||||
|
||||
|
||||
def _model_fallback(c: dict, ctx: CompileContext) -> AgentMiddleware:
|
||||
models = [resolve_model(m, ctx) for m in c["models"]]
|
||||
return ModelFallbackMiddleware(models[0], *models[1:])
|
||||
|
||||
|
||||
def _pii(c: dict, ctx: CompileContext) -> AgentMiddleware:
|
||||
return PIIMiddleware(
|
||||
c["pii_type"],
|
||||
strategy=c.get("strategy", "redact"),
|
||||
detector=c.get("detector"),
|
||||
apply_to_input=c.get("apply_to_input", True),
|
||||
apply_to_output=c.get("apply_to_output", False),
|
||||
apply_to_tool_results=c.get("apply_to_tool_results", False),
|
||||
)
|
||||
|
||||
|
||||
def _llm_tool_selector(c: dict, ctx: CompileContext) -> AgentMiddleware:
|
||||
kw = _pick(c, ["max_tools", "always_include"])
|
||||
return LLMToolSelectorMiddleware(model=resolve_model(c.get("model"), ctx), **kw)
|
||||
|
||||
|
||||
# retry_on string names (from the JSON config) -> real exception types the lib expects.
|
||||
# ("http_error"/"httpx"/"http" resolve lazily to httpx.HTTPError in _retry_exceptions.)
|
||||
_RETRY_EXC_MAP: dict[str, type[BaseException]] = {
|
||||
"exception": Exception,
|
||||
"timeout": TimeoutError,
|
||||
"timeout_error": TimeoutError,
|
||||
"connection": ConnectionError,
|
||||
"connection_error": ConnectionError,
|
||||
"value_error": ValueError,
|
||||
"key_error": KeyError,
|
||||
"runtime_error": RuntimeError,
|
||||
}
|
||||
|
||||
|
||||
def _retry_exceptions(names: list[str]) -> tuple[type[BaseException], ...]:
|
||||
out: list[type[BaseException]] = []
|
||||
for n in names or []:
|
||||
key = str(n).strip().lower()
|
||||
if key in ("http_error", "httpx", "http"):
|
||||
import httpx
|
||||
|
||||
out.append(httpx.HTTPError)
|
||||
continue
|
||||
exc = _RETRY_EXC_MAP.get(key)
|
||||
if exc is not None:
|
||||
out.append(exc)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _tool_retry(c: dict, ctx: CompileContext) -> AgentMiddleware:
|
||||
kw = _pick(c, ["max_retries", "tools", "on_failure", "backoff_factor", "initial_delay", "max_delay", "jitter"])
|
||||
retry_on = _retry_exceptions(c.get("retry_on") or [])
|
||||
if retry_on:
|
||||
kw["retry_on"] = retry_on
|
||||
return ToolRetryMiddleware(**kw)
|
||||
|
||||
|
||||
def _model_retry(c: dict, ctx: CompileContext) -> AgentMiddleware:
|
||||
kw = _pick(c, ["max_retries", "on_failure", "backoff_factor", "initial_delay", "max_delay", "jitter"])
|
||||
# Pass retry_on through like _tool_retry does - it was dropped before, so "retry only on
|
||||
# timeouts/http errors" configs silently retried on ANY exception (audit F5).
|
||||
retry_on = _retry_exceptions(c.get("retry_on") or [])
|
||||
if retry_on:
|
||||
kw["retry_on"] = retry_on
|
||||
return ModelRetryMiddleware(**kw)
|
||||
|
||||
|
||||
def _tool_emulator(c: dict, ctx: CompileContext) -> AgentMiddleware:
|
||||
kw: dict[str, Any] = {}
|
||||
if c.get("tools") is not None:
|
||||
kw["tools"] = c["tools"]
|
||||
if c.get("model"):
|
||||
kw["model"] = resolve_model(c["model"], ctx)
|
||||
return LLMToolEmulator(**kw)
|
||||
|
||||
|
||||
def _context_editing(c: dict, ctx: CompileContext) -> AgentMiddleware:
|
||||
edits = [ClearToolUsesEdit(**e) for e in c.get("edits", [])] or [ClearToolUsesEdit()]
|
||||
return ContextEditingMiddleware(edits=edits)
|
||||
|
||||
|
||||
def _anthropic_prompt_caching(c: dict, ctx: CompileContext) -> AgentMiddleware:
|
||||
# Provider-specific: lives in langchain-anthropic (install extra: providers).
|
||||
try:
|
||||
from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware
|
||||
except ImportError as e: # pragma: no cover - depends on optional extra
|
||||
raise ImportError(
|
||||
"anthropic_prompt_caching needs `langchain-anthropic` "
|
||||
"(pip install -e '.[providers]')."
|
||||
) from e
|
||||
return AnthropicPromptCachingMiddleware(**_pick(c, ["ttl"]))
|
||||
|
||||
|
||||
def _openai_moderation(c: dict, ctx: CompileContext) -> AgentMiddleware:
|
||||
try:
|
||||
from langchain_openai.middleware import OpenAIModerationMiddleware
|
||||
except ImportError as e: # pragma: no cover - depends on optional extra
|
||||
raise ImportError(
|
||||
"openai_moderation needs `langchain-openai` (pip install -e '.[providers]')."
|
||||
) from e
|
||||
# langchain-openai (>=1.3) renamed the toggles apply_to_* -> check_* (and added
|
||||
# check_tool_results / exit_behavior / model / violation_message). Map from our schema's
|
||||
# apply_to_* names (shared with `pii`, and already present in saved configs) to the
|
||||
# library's current kwargs so enabling this middleware doesn't fail to compile.
|
||||
kw: dict[str, Any] = {}
|
||||
for schema_key, lib_key in (
|
||||
("apply_to_input", "check_input"),
|
||||
("apply_to_output", "check_output"),
|
||||
("apply_to_tool_results", "check_tool_results"),
|
||||
):
|
||||
if c.get(schema_key) is not None:
|
||||
kw[lib_key] = c[schema_key]
|
||||
# Pass-through kwargs the library accepts as-is (available via Advanced JSON).
|
||||
for k in ("exit_behavior", "model", "violation_message"):
|
||||
if c.get(k) is not None:
|
||||
kw[k] = c[k]
|
||||
return OpenAIModerationMiddleware(**kw)
|
||||
|
||||
|
||||
# --- custom / advanced builders (declarative rules -> hooks) ---------------
|
||||
|
||||
|
||||
class _DynamicModelByStateMiddleware(AgentMiddleware):
|
||||
"""Switch the model at runtime by a state expression. Implemented as a class with BOTH the
|
||||
sync and async wrap hooks: the previous `@wrap_model_call`-on-a-sync-function version only
|
||||
provided the sync path, so it raised NotImplementedError under astream/ainvoke (the runtime
|
||||
path) - it never actually worked live. Wiring the agent-node `dynamic_model` field depends
|
||||
on this being async-safe (audit F9)."""
|
||||
|
||||
def __init__(self, rules: list[dict], default: Any, ctx: CompileContext):
|
||||
super().__init__()
|
||||
self._rules = rules or []
|
||||
self._default = default
|
||||
self._ctx = ctx
|
||||
|
||||
def _apply(self, request):
|
||||
chosen = self._default
|
||||
for r in self._rules:
|
||||
try:
|
||||
if eval_truthy(r["when"], dict(request.state or {})):
|
||||
chosen = r["use"]
|
||||
break
|
||||
except Exception: # noqa: BLE001 - a bad rule shouldn't kill the run
|
||||
continue
|
||||
return request.override(model=resolve_model(chosen, self._ctx)) if chosen else request
|
||||
|
||||
def wrap_model_call(self, request, handler): # type: ignore[no-untyped-def]
|
||||
return handler(self._apply(request))
|
||||
|
||||
async def awrap_model_call(self, request, handler): # type: ignore[no-untyped-def]
|
||||
return await handler(self._apply(request))
|
||||
|
||||
|
||||
def _dynamic_model_by_state(c: dict, ctx: CompileContext) -> AgentMiddleware:
|
||||
return _DynamicModelByStateMiddleware(c.get("rules", []), c.get("default"), ctx)
|
||||
|
||||
|
||||
class _ToolFilterByContextMiddleware(AgentMiddleware):
|
||||
"""Show/hide tools at runtime based on the run's context (auth state, role, flags). Same
|
||||
sync+async fix as _DynamicModelByStateMiddleware - it was sync-only and thus a no-op-then-
|
||||
crash under async execution."""
|
||||
|
||||
def __init__(self, expose_when: dict, gated: set[str]):
|
||||
super().__init__()
|
||||
self._expose_when = expose_when
|
||||
self._gated = gated
|
||||
|
||||
def _apply(self, request):
|
||||
rt_ctx = getattr(getattr(request, "runtime", None), "context", None) or {}
|
||||
allowed = _context_matches(self._expose_when, rt_ctx if isinstance(rt_ctx, dict) else {})
|
||||
if not allowed and self._gated:
|
||||
kept = [t for t in (request.tools or []) if getattr(t, "name", None) not in self._gated]
|
||||
return request.override(tools=kept)
|
||||
return request
|
||||
|
||||
def wrap_model_call(self, request, handler): # type: ignore[no-untyped-def]
|
||||
return handler(self._apply(request))
|
||||
|
||||
async def awrap_model_call(self, request, handler): # type: ignore[no-untyped-def]
|
||||
return await handler(self._apply(request))
|
||||
|
||||
|
||||
def _tool_filter_by_context(c: dict, ctx: CompileContext) -> AgentMiddleware:
|
||||
return _ToolFilterByContextMiddleware(c.get("expose_when", {}), set(c.get("tools", [])))
|
||||
|
||||
|
||||
_GUARDRAIL_REDACTION = "[redacted]"
|
||||
|
||||
|
||||
class _GuardrailRegexMiddleware(AgentMiddleware):
|
||||
"""Regex content guardrail honoring `apply_to` (input/output/both) and all three actions
|
||||
(block/redact/flag) - previously only the OUTPUT was scanned and only `block` did anything;
|
||||
`apply_to`, `redact` and `flag` were silently ignored (audit F4).
|
||||
|
||||
- block: replace the offending message with a fixed notice (existing behavior kept).
|
||||
- redact: mask each matched span with `[redacted]`, leaving the rest of the message intact.
|
||||
- flag: keep the content but tag `additional_kwargs['guardrail_flagged']` and log it.
|
||||
|
||||
Scanning input happens in before_model (so redaction is applied before the model sees it);
|
||||
scanning output happens in after_model (the model's reply)."""
|
||||
|
||||
def __init__(self, patterns: list[str], on_match: str = "block", apply_to: str = "output"):
|
||||
super().__init__()
|
||||
self._patterns = [re.compile(p) for p in patterns]
|
||||
self._mode = on_match
|
||||
self._apply_to = apply_to
|
||||
|
||||
def _rewrite(self, msg: Any):
|
||||
"""Return a replacement message if a pattern matches under the mode, else None."""
|
||||
text = _msg_text(msg)
|
||||
if not self._patterns or not any(p.search(text) for p in self._patterns):
|
||||
return None
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
is_ai = getattr(msg, "type", None) == "ai"
|
||||
make = AIMessage if is_ai else HumanMessage
|
||||
if self._mode == "block":
|
||||
return make(content="[blocked by content guardrail]")
|
||||
if self._mode == "redact":
|
||||
redacted = text
|
||||
for p in self._patterns:
|
||||
redacted = p.sub(_GUARDRAIL_REDACTION, redacted)
|
||||
return make(content=redacted)
|
||||
# flag: keep content, mark it for review (visible in the transcript / trace) + log.
|
||||
ak = dict(getattr(msg, "additional_kwargs", {}) or {})
|
||||
ak["guardrail_flagged"] = True
|
||||
log.warning("guardrail_regex flagged a message (a pattern matched)")
|
||||
return make(content=text, additional_kwargs=ak)
|
||||
|
||||
def _scan(self, state: dict, which: str):
|
||||
from langchain_core.messages import RemoveMessage
|
||||
|
||||
msgs = state.get("messages") or []
|
||||
want = ("human", "user") if which == "input" else ("ai",)
|
||||
target = next((m for m in reversed(msgs) if getattr(m, "type", None) in want), None)
|
||||
if target is None:
|
||||
return None
|
||||
repl = self._rewrite(target)
|
||||
if repl is None:
|
||||
return None
|
||||
tid = getattr(target, "id", None)
|
||||
return {"messages": [RemoveMessage(id=tid), repl] if tid else [repl]}
|
||||
|
||||
def before_model(self, state, runtime=None): # type: ignore[no-untyped-def]
|
||||
if self._apply_to in ("input", "both"):
|
||||
return self._scan(state, "input")
|
||||
return None
|
||||
|
||||
def after_model(self, state, runtime=None): # type: ignore[no-untyped-def]
|
||||
if self._apply_to in ("output", "both"):
|
||||
return self._scan(state, "output")
|
||||
return None
|
||||
|
||||
|
||||
def _guardrail_regex(c: dict, ctx: CompileContext) -> AgentMiddleware:
|
||||
return _GuardrailRegexMiddleware(
|
||||
patterns=c.get("patterns", []),
|
||||
on_match=c.get("on_match", "block"),
|
||||
apply_to=c.get("apply_to", "output"),
|
||||
)
|
||||
|
||||
|
||||
class _TenantBudgetState(AgentState):
|
||||
# Run-scoped token tally: UntrackedValue channels are NOT checkpointed, so this resets at
|
||||
# the start of every run (invocation) - giving true per-RUN scoping, unlike the old code
|
||||
# that summed usage over the whole persisted thread (audit F3). PrivateStateAttr keeps it
|
||||
# out of the agent's input/output schema so it never leaks to the workflow state.
|
||||
_forge_run_tokens: NotRequired[Annotated[int, UntrackedValue, PrivateStateAttr]]
|
||||
# Thread-scoped USD tally: a normal (checkpointed) channel, so it accumulates across the
|
||||
# runs of a thread - matching `max_usd_per_thread`.
|
||||
_forge_thread_cost_usd: NotRequired[Annotated[float, PrivateStateAttr]]
|
||||
|
||||
|
||||
class _TenantBudgetMiddleware(AgentMiddleware):
|
||||
"""Stop the run/thread when accumulated tokens or USD exceed a cap.
|
||||
|
||||
- `max_tokens_per_run` is now scoped to the current RUN (was: whole persisted thread).
|
||||
- `max_usd_per_thread` is implemented via span-style pricing (forge.tracing.pricing.price)
|
||||
accumulated across the thread - previously the field was accepted and ignored (F3).
|
||||
Cost is only priced when a USD cap is set (keeps the token-only path free of lookups)."""
|
||||
|
||||
state_schema = _TenantBudgetState # type: ignore[assignment]
|
||||
|
||||
def __init__(self, max_tokens_per_run: int | None, max_usd_per_thread: float | None, on_exceed: str = "end"):
|
||||
super().__init__()
|
||||
self._max_tokens = max_tokens_per_run
|
||||
self._max_usd = max_usd_per_thread
|
||||
self._on_exceed = on_exceed
|
||||
|
||||
def _exceeded(self, reason: str):
|
||||
if self._on_exceed == "error":
|
||||
raise RuntimeError(f"Tenant budget exceeded: {reason}")
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
return {"jump_to": "end", "messages": [AIMessage(content=f"[budget] run stopped: {reason}")]}
|
||||
|
||||
@hook_config(can_jump_to=["end"])
|
||||
def before_model(self, state, runtime=None): # type: ignore[no-untyped-def]
|
||||
run_tokens = state.get("_forge_run_tokens", 0) or 0
|
||||
thread_cost = state.get("_forge_thread_cost_usd", 0.0) or 0.0
|
||||
if self._max_tokens and run_tokens >= self._max_tokens:
|
||||
return self._exceeded(f"{run_tokens} >= {self._max_tokens} tokens (this run)")
|
||||
if self._max_usd and thread_cost >= self._max_usd:
|
||||
return self._exceeded(f"${thread_cost:.4f} >= ${self._max_usd} (this thread)")
|
||||
return None
|
||||
|
||||
def after_model(self, state, runtime=None): # type: ignore[no-untyped-def]
|
||||
msgs = state.get("messages") or []
|
||||
if not msgs:
|
||||
return None
|
||||
last = msgs[-1]
|
||||
usage = getattr(last, "usage_metadata", None) or (
|
||||
last.get("usage_metadata") if isinstance(last, dict) else None
|
||||
)
|
||||
if not usage:
|
||||
return None
|
||||
in_tok = usage.get("input_tokens", 0) or 0
|
||||
out_tok = usage.get("output_tokens", 0) or 0
|
||||
total = usage.get("total_tokens", in_tok + out_tok) or 0
|
||||
update: dict[str, Any] = {"_forge_run_tokens": (state.get("_forge_run_tokens", 0) or 0) + total}
|
||||
if self._max_usd:
|
||||
from forge.tracing.pricing import price
|
||||
|
||||
rm = getattr(last, "response_metadata", None) or {}
|
||||
model_name = rm.get("model_name") or rm.get("model")
|
||||
details = usage.get("input_token_details") or {}
|
||||
cost = price(
|
||||
model_name, in_tok, out_tok,
|
||||
cache_read_tokens=details.get("cache_read", 0) or 0,
|
||||
cache_creation_tokens=details.get("cache_creation", 0) or 0,
|
||||
)
|
||||
update["_forge_thread_cost_usd"] = (state.get("_forge_thread_cost_usd", 0.0) or 0.0) + cost
|
||||
return update
|
||||
|
||||
|
||||
def _tenant_budget(c: dict, ctx: CompileContext) -> AgentMiddleware:
|
||||
return _TenantBudgetMiddleware(
|
||||
max_tokens_per_run=c.get("max_tokens_per_run"),
|
||||
max_usd_per_thread=c.get("max_usd_per_thread"),
|
||||
on_exceed=c.get("on_exceed", "end"),
|
||||
)
|
||||
|
||||
|
||||
def _cache_answer_text(response: Any) -> str | None:
|
||||
"""Extract a cacheable FINAL-answer string from a model response, or None. Skips a
|
||||
tool-calling turn (not a final answer) and empty/structured responses so we never cache
|
||||
a mid-loop step or a blank reply."""
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
msg: Any = None
|
||||
if isinstance(response, AIMessage):
|
||||
msg = response
|
||||
else:
|
||||
result = getattr(response, "result", None)
|
||||
if result is None: # ExtendedModelResponse wraps the ModelResponse
|
||||
mr = getattr(response, "model_response", None)
|
||||
result = getattr(mr, "result", None)
|
||||
if result:
|
||||
msg = next(
|
||||
(m for m in result if getattr(m, "type", None) in ("ai", "AIMessageChunk")),
|
||||
result[0],
|
||||
)
|
||||
if msg is None or getattr(msg, "tool_calls", None):
|
||||
return None
|
||||
text = _msg_text(msg).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
class _SemanticCacheMiddleware(AgentMiddleware):
|
||||
"""Answer cache keyed by question MEANING (vector similarity), wiring the built-but-unused
|
||||
SemanticCacheService into the agent loop (audit A). On the FIRST model call of a fresh user
|
||||
turn it looks the question up; a hit SHORT-CIRCUITS the whole model/tool loop with the cached
|
||||
answer (returned as an AIMessage, which ends the ReAct loop). A fresh non-tool answer is stored
|
||||
after. Gated per agent (only attached when the middleware is configured on the node).
|
||||
|
||||
Async-only: the embedder + cache store are async, so awrap_model_call does the work; the sync
|
||||
wrap_model_call degrades to a pass-through (no caching) rather than crash - Forge's runtime
|
||||
always drives agents with astream/ainvoke. Every cache call is fully guarded: a cache failure
|
||||
(embedder cold, Chroma error) must never break a run."""
|
||||
|
||||
def __init__(self, tenant_id: str, project_id: str, *, threshold: float, ttl: int,
|
||||
scope: str, min_chars: int):
|
||||
super().__init__()
|
||||
self._tenant = tenant_id
|
||||
self._project = project_id
|
||||
self._threshold = threshold
|
||||
self._ttl = ttl
|
||||
self._scope = scope
|
||||
self._min = min_chars
|
||||
|
||||
def _question(self, request) -> str | None:
|
||||
"""The user's question for THIS turn: the last message must be a human message (i.e.
|
||||
the model is about to answer a fresh question, not continue a tool round). Returns None
|
||||
mid-loop so we neither look up nor store a partial/tool step."""
|
||||
msgs = getattr(request, "messages", None) or []
|
||||
if not msgs:
|
||||
return None
|
||||
last = msgs[-1]
|
||||
if getattr(last, "type", None) != "human":
|
||||
return None
|
||||
return _msg_text(last).strip() or None
|
||||
|
||||
def wrap_model_call(self, request, handler): # type: ignore[no-untyped-def]
|
||||
return handler(request)
|
||||
|
||||
async def awrap_model_call(self, request, handler): # type: ignore[no-untyped-def]
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.services.semantic_cache import SemanticCacheService
|
||||
|
||||
question = self._question(request)
|
||||
cacheable = bool(question) and len(question) >= self._min
|
||||
if cacheable:
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
cached = await SemanticCacheService.lookup(
|
||||
s, self._tenant, self._project, question,
|
||||
scope=self._scope, threshold=self._threshold, ttl=self._ttl,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a cache lookup must never break the run
|
||||
log.debug("semantic_cache lookup failed", exc_info=True)
|
||||
cached = None
|
||||
if cached is not None:
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
return AIMessage(content=cached)
|
||||
|
||||
response = await handler(request)
|
||||
|
||||
if cacheable:
|
||||
answer = _cache_answer_text(response)
|
||||
if answer:
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
await SemanticCacheService.store(
|
||||
s, self._tenant, self._project, question, answer, scope=self._scope,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - a cache write must never break the run
|
||||
log.debug("semantic_cache store failed", exc_info=True)
|
||||
return response
|
||||
|
||||
|
||||
def _semantic_cache(c: dict, ctx: CompileContext) -> AgentMiddleware:
|
||||
from forge.services.semantic_cache import CACHE_DEFAULT_THRESHOLD, CACHE_DEFAULT_TTL_SECONDS
|
||||
|
||||
return _SemanticCacheMiddleware(
|
||||
ctx.tenant_id, ctx.project_id,
|
||||
threshold=float(c.get("threshold", CACHE_DEFAULT_THRESHOLD)),
|
||||
ttl=int(c.get("ttl", CACHE_DEFAULT_TTL_SECONDS)),
|
||||
scope=str(c.get("scope") or "default"),
|
||||
min_chars=int(c.get("min_question_chars", 8)),
|
||||
)
|
||||
|
||||
|
||||
MW_BUILDERS: dict[str, Builder] = {
|
||||
"summarization": _summarization,
|
||||
"human_in_the_loop": lambda c, ctx: HumanInTheLoopMiddleware(interrupt_on=c["interrupt_on"]),
|
||||
"model_call_limit": lambda c, ctx: ModelCallLimitMiddleware(
|
||||
**_pick(c, ["thread_limit", "run_limit", "exit_behavior"])
|
||||
),
|
||||
"tool_call_limit": lambda c, ctx: ToolCallLimitMiddleware(
|
||||
**_pick(c, ["tool_name", "thread_limit", "run_limit", "exit_behavior"])
|
||||
),
|
||||
"model_fallback": _model_fallback,
|
||||
"pii": _pii,
|
||||
"todo": lambda c, ctx: TodoListMiddleware(**_pick(c, ["system_prompt", "tool_description"])),
|
||||
"llm_tool_selector": _llm_tool_selector,
|
||||
"tool_retry": _tool_retry,
|
||||
"model_retry": _model_retry,
|
||||
"tool_emulator": _tool_emulator,
|
||||
"context_editing": _context_editing,
|
||||
"anthropic_prompt_caching": _anthropic_prompt_caching,
|
||||
"openai_moderation": _openai_moderation,
|
||||
# custom / advanced
|
||||
# (request_signing was removed: it was a no-op stub - auth injection already
|
||||
# happens inside materialized REST tools via the AuthResolver.)
|
||||
"dynamic_model_by_state": _dynamic_model_by_state,
|
||||
"tool_filter_by_context": _tool_filter_by_context,
|
||||
"guardrail_regex": _guardrail_regex,
|
||||
"tenant_budget": _tenant_budget,
|
||||
"semantic_cache": _semantic_cache,
|
||||
}
|
||||
|
||||
|
||||
def build_middleware(stack: list[dict] | None, ctx: CompileContext) -> list[AgentMiddleware]:
|
||||
"""Compile a middleware stack list into concrete middleware instances.
|
||||
|
||||
Disabled entries are skipped. Unknown types raise (caught upstream by the
|
||||
validator, which reports them as field-level errors before compile).
|
||||
"""
|
||||
out: list[AgentMiddleware] = []
|
||||
for m in stack or []:
|
||||
if m.get("enabled", True) is False:
|
||||
continue
|
||||
mtype = m.get("type")
|
||||
builder = MW_BUILDERS.get(mtype)
|
||||
if builder is None:
|
||||
raise ValueError(f"Unknown middleware type: {mtype!r}")
|
||||
out.append(builder(m.get("config") or {}, ctx))
|
||||
return out
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Resolve a model-ref string to a chat model.
|
||||
|
||||
Doc 2 §9 `resolve_model`: parse the provider-prefixed id; native packages for the
|
||||
big three, gateways for the rest. A `fake:` scheme returns an offline model so the
|
||||
engine, tests, and the playground "dry run" work with no API keys or network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from langchain_core.language_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
from forge.config import settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from forge.engine.context import CompileContext
|
||||
|
||||
|
||||
# Sensible per-provider default model, used when a project has a provider key
|
||||
# configured but no explicit `default_model`. Keeps an agent node with no model
|
||||
# from silently degrading to the offline `fake:` model. Ordered by preference.
|
||||
_PROVIDER_DEFAULT_MODEL: dict[str, str] = {
|
||||
"openai": "openai:gpt-4.1-mini",
|
||||
"anthropic": "anthropic:claude-sonnet-4-6",
|
||||
"google_genai": "google_genai:gemini-2.5-flash",
|
||||
"google": "google_genai:gemini-2.5-flash",
|
||||
}
|
||||
|
||||
|
||||
def default_model_for_credentials(creds: dict | None) -> str | None:
|
||||
"""Pick a real model ref for a project that has provider keys but no explicit
|
||||
default_model. Returns None when no known provider key is present (caller then
|
||||
falls back to the global offline default)."""
|
||||
for provider in ("openai", "anthropic", "google_genai", "google"):
|
||||
if provider in (creds or {}):
|
||||
return _PROVIDER_DEFAULT_MODEL[provider]
|
||||
return None
|
||||
|
||||
|
||||
# Cheapest capable model per provider - for high-volume, low-stakes calls like intent
|
||||
# classification, where a frontier model is wasted spend.
|
||||
_PROVIDER_CHEAP_MODEL: dict[str, str] = {
|
||||
"openai": "openai:gpt-4.1-nano",
|
||||
"anthropic": "anthropic:claude-haiku-4-5",
|
||||
"google_genai": "google_genai:gemini-2.5-flash",
|
||||
"google": "google_genai:gemini-2.5-flash",
|
||||
}
|
||||
|
||||
|
||||
def cheap_model_for_credentials(creds: dict | None) -> str | None:
|
||||
"""The cheapest model for a project's configured provider (classifier default)."""
|
||||
for provider in ("openai", "anthropic", "google_genai", "google"):
|
||||
if provider in (creds or {}):
|
||||
return _PROVIDER_CHEAP_MODEL[provider]
|
||||
return None
|
||||
|
||||
|
||||
def make_fake_model(reply: str | None = None) -> BaseChatModel:
|
||||
"""An offline chat model that returns a fixed final answer on every call.
|
||||
|
||||
No tool calls => any ReAct loop terminates immediately. Uses a cycled iterator
|
||||
so repeated invocations never exhaust. `bind_tools` is a no-op so tool-bound
|
||||
agents still run offline (tools are bound but never invoked by this model).
|
||||
"""
|
||||
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
|
||||
|
||||
class _ToolTolerantFake(GenericFakeChatModel):
|
||||
def bind_tools(self, tools=None, **kwargs): # type: ignore[override]
|
||||
return self
|
||||
|
||||
text = reply or "Hello from Forge's offline model. Configure a real provider model to go live."
|
||||
return _ToolTolerantFake(messages=itertools.cycle([AIMessage(content=text)]))
|
||||
|
||||
|
||||
def resolve_model(
|
||||
model_ref: str | BaseChatModel | None,
|
||||
ctx: CompileContext | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> BaseChatModel:
|
||||
# Already a constructed model (e.g. injected by a test or middleware).
|
||||
if isinstance(model_ref, BaseChatModel):
|
||||
return model_ref
|
||||
|
||||
if not model_ref:
|
||||
model_ref = (getattr(ctx, "default_model", None) if ctx else None) or settings.default_model
|
||||
|
||||
if isinstance(model_ref, str) and model_ref.startswith("fake"):
|
||||
# "fake" or "fake:<text>"
|
||||
_, _, reply = model_ref.partition(":")
|
||||
return make_fake_model(reply or None)
|
||||
|
||||
# Real provider / gateway. init_chat_model parses "provider:model" and binds the
|
||||
# right integration package; raises a clear ImportError if it isn't installed.
|
||||
from langchain.chat_models import init_chat_model
|
||||
|
||||
clean = {k: v for k, v in (params or {}).items() if v is not None}
|
||||
|
||||
# Inject the project's provider API key (resolved from a secret by the runtime
|
||||
# assembler into ctx.provider_credentials). Falls back to the provider's env var.
|
||||
provider = model_ref.split(":", 1)[0] if ":" in model_ref else None
|
||||
creds = getattr(ctx, "provider_credentials", None) or {}
|
||||
key = creds.get(provider) if provider else None
|
||||
if key:
|
||||
param = "google_api_key" if provider in ("google_genai", "google") else "api_key"
|
||||
clean.setdefault(param, key)
|
||||
|
||||
# OpenAI streams tokens without usage by default, so every streamed run reported 0 tokens /
|
||||
# $0 cost. `stream_usage=True` adds stream_options.include_usage so the final chunk carries
|
||||
# usage_metadata (Anthropic/Google already stream usage). Lets the tracer + meters price runs.
|
||||
if provider == "openai":
|
||||
clean.setdefault("stream_usage", True)
|
||||
|
||||
# Keep-alive: hand OpenAI models one process-wide connection pool so back-to-back turns and
|
||||
# runs reuse a warm TLS connection instead of re-handshaking on each per-run graph compile.
|
||||
# Scoped to OpenAI: langchain-openai accepts `http_async_client`, whereas the Anthropic and
|
||||
# Google integrations manage (and pool) their own transport. Skipped if the caller already
|
||||
# supplied a client (e.g. a test mock).
|
||||
if (
|
||||
settings.llm_http_keepalive
|
||||
and provider == "openai"
|
||||
and "http_async_client" not in clean
|
||||
):
|
||||
from forge.util.http import shared_llm_async_client
|
||||
|
||||
clean["http_async_client"] = shared_llm_async_client()
|
||||
|
||||
return init_chat_model(model_ref, **clean)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Node Type Registry - `node.type` -> factory + typed ports + schema id.
|
||||
|
||||
Doc 2 §6. New node types are added by registering a `NodeSpec`; the compiler,
|
||||
the validator, and the UI palette all read from this registry without edits.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
# IOType enum (Doc 4 §1). A connection is valid iff source/target are compatible.
|
||||
IO_TYPES = frozenset(
|
||||
{"messages", "text", "json", "tool", "embedding", "vector", "any", "control"}
|
||||
)
|
||||
|
||||
|
||||
def io_compatible(source: str, target: str) -> bool:
|
||||
"""`any` matches all; `control` only connects to `control`; else exact match."""
|
||||
if source == "control" or target == "control":
|
||||
return source == "control" and target == "control"
|
||||
if source == "any" or target == "any":
|
||||
return True
|
||||
return source == target
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Port:
|
||||
id: str
|
||||
io_type: str
|
||||
direction: str # "in" | "out"
|
||||
label: str | None = None
|
||||
required: bool = True
|
||||
many: bool = False
|
||||
|
||||
|
||||
# A NodeFactory takes (validated config, CompileContext) and returns a LangGraph
|
||||
# node - either a plain callable `(state) -> dict` or a compiled Runnable/graph.
|
||||
NodeFactory = Callable[[dict, "object"], object]
|
||||
# summarize(config) -> short glanceable lines for the canvas node body.
|
||||
Summarizer = Callable[[dict], list[str]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeSpec:
|
||||
type: str
|
||||
schema_id: str
|
||||
input_ports: list[Port]
|
||||
output_ports: list[Port]
|
||||
factory: NodeFactory
|
||||
allows_cycle: bool = False
|
||||
summarize: Summarizer | None = None
|
||||
category: str = "flow" # flow|agents|model_tools|knowledge|human|integrations
|
||||
label: str = ""
|
||||
description: str = ""
|
||||
|
||||
|
||||
NODE_REGISTRY: dict[str, NodeSpec] = {}
|
||||
|
||||
|
||||
def register(spec: NodeSpec) -> NodeSpec:
|
||||
if spec.type in NODE_REGISTRY:
|
||||
raise ValueError(f"Node type already registered: {spec.type!r}")
|
||||
NODE_REGISTRY[spec.type] = spec
|
||||
return spec
|
||||
|
||||
|
||||
def get_spec(node_type: str) -> NodeSpec:
|
||||
try:
|
||||
return NODE_REGISTRY[node_type]
|
||||
except KeyError as e:
|
||||
known = ", ".join(sorted(NODE_REGISTRY)) or "<none registered>"
|
||||
raise KeyError(f"Unknown node type {node_type!r}. Registered: {known}") from e
|
||||
|
||||
|
||||
def all_specs() -> list[NodeSpec]:
|
||||
return list(NODE_REGISTRY.values())
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Build a runtime state schema (TypedDict) from a workflow's declared state.
|
||||
|
||||
Doc 2 §6 / Doc 4 §2: state is a `TypedDict` (NOT pydantic, hard v1 constraint).
|
||||
Each field maps to a python type + a reducer so parallel writes merge correctly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import operator
|
||||
from typing import Annotated, Any, TypedDict
|
||||
|
||||
from langgraph.graph import add_messages
|
||||
|
||||
# Doc 4 StateFieldSpec.type -> python type used for the channel.
|
||||
PY_TYPES: dict[str, Any] = {
|
||||
"list[message]": list,
|
||||
"list[str]": list,
|
||||
"list[json]": list,
|
||||
"str": str,
|
||||
"int": int,
|
||||
"float": float,
|
||||
"bool": bool,
|
||||
"json": dict,
|
||||
}
|
||||
|
||||
|
||||
def _merge(a: dict | None, b: dict | None) -> dict:
|
||||
return {**(a or {}), **(b or {})}
|
||||
|
||||
|
||||
# Doc 4 StateFieldSpec.reducer -> binary reducer. "last" => no reducer (LastValue/overwrite).
|
||||
REDUCERS: dict[str, Any] = {
|
||||
"add_messages": add_messages,
|
||||
"add": operator.add,
|
||||
"merge": _merge,
|
||||
# "last" intentionally absent: a plain annotation => overwrite semantics.
|
||||
}
|
||||
|
||||
# Sensible default so agent nodes always have a messages channel to read/write.
|
||||
_DEFAULT_MESSAGES = {"type": "list[message]", "reducer": "add_messages"}
|
||||
|
||||
|
||||
def build_state_typeddict(state_cfg: dict[str, dict], name: str = "WorkflowState") -> type:
|
||||
"""Compile a state-schema dict into a `TypedDict` with reducer annotations.
|
||||
|
||||
Example input (executable JSON `state`):
|
||||
{"messages": {"type": "list[message]", "reducer": "add_messages"},
|
||||
"findings": {"type": "list[str]", "reducer": "add"},
|
||||
"intent": {"type": "str", "reducer": "last"}}
|
||||
"""
|
||||
cfg = dict(state_cfg or {})
|
||||
cfg.setdefault("messages", _DEFAULT_MESSAGES)
|
||||
|
||||
annotations: dict[str, Any] = {}
|
||||
for field, spec in cfg.items():
|
||||
py = PY_TYPES.get(spec.get("type", "json"), Any)
|
||||
reducer_name = spec.get("reducer", "last")
|
||||
reducer = REDUCERS.get(reducer_name)
|
||||
annotations[field] = Annotated[py, reducer] if reducer is not None else py
|
||||
|
||||
# Functional TypedDict carries Annotated reducer metadata that LangGraph reads
|
||||
# when building channels. total=False: nodes may write a partial state update.
|
||||
return TypedDict(name, annotations, total=False) # type: ignore[operator]
|
||||
@@ -0,0 +1,6 @@
|
||||
"""RAG: embeddings, vector store (Chroma or pgvector), ingestion, Q&A, hybrid search."""
|
||||
|
||||
from forge.knowledge.embeddings import Embedder, resolve_embedder
|
||||
from forge.knowledge.store import ChromaStore, Hit, make_store
|
||||
|
||||
__all__ = ["Embedder", "resolve_embedder", "ChromaStore", "Hit", "make_store"]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Website crawling for knowledge ingestion.
|
||||
|
||||
`crawl_site` does a same-domain BFS from a start URL (SSRF-guarded, redirect-safe) up to
|
||||
`max_pages`/`max_depth`, honoring robots.txt with a small politeness delay between fetches, and
|
||||
returns {url: text} so each page keeps its own provenance. `extract_links` (pure, testable)
|
||||
pulls same-domain links from a page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from urllib.robotparser import RobotFileParser
|
||||
|
||||
# Bounds so a caller-supplied meta can't launch an unbounded crawl (DoS / runaway cost).
|
||||
MAX_PAGES_CAP = 200
|
||||
MAX_DEPTH_CAP = 5
|
||||
DEFAULT_MAX_PAGES = 25
|
||||
DEFAULT_MAX_DEPTH = 2
|
||||
DEFAULT_DELAY_SECONDS = 0.3 # politeness gap between fetches (also raised to robots Crawl-delay)
|
||||
_USER_AGENT = "ForgeKnowledgeBot"
|
||||
|
||||
|
||||
def extract_links(html: str, base_url: str) -> list[str]:
|
||||
"""Absolute, same-domain http(s) links from a page (deduped, fragments stripped)."""
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
hrefs = [a.get("href") for a in soup.find_all("a", href=True)]
|
||||
except Exception: # noqa: BLE001 - fall back to a crude regex
|
||||
import re
|
||||
hrefs = re.findall(r'href=["\']([^"\']+)["\']', html or "")
|
||||
|
||||
base_host = urlparse(base_url).netloc
|
||||
out: list[str] = []
|
||||
for href in hrefs:
|
||||
if not href:
|
||||
continue
|
||||
u = urljoin(base_url, href).split("#")[0]
|
||||
p = urlparse(u)
|
||||
if p.scheme in ("http", "https") and p.netloc == base_host and u not in out:
|
||||
out.append(u)
|
||||
return out
|
||||
|
||||
|
||||
async def _load_robots(client, start_url: str) -> RobotFileParser | None:
|
||||
"""Fetch + parse the site's robots.txt through the SSRF-guarded client (never urllib's own
|
||||
opener, which would bypass the egress guard). None on any failure => treat as allow-all,
|
||||
the standard behavior when a site publishes no robots.txt."""
|
||||
from forge.util.ssrf import guarded_get
|
||||
|
||||
p = urlparse(start_url)
|
||||
robots_url = f"{p.scheme}://{p.netloc}/robots.txt"
|
||||
try:
|
||||
r = await guarded_get(client, robots_url, timeout=10, follow_redirects=True)
|
||||
if r.status_code >= 400:
|
||||
return None
|
||||
rp = RobotFileParser()
|
||||
rp.parse(r.text.splitlines())
|
||||
return rp
|
||||
except Exception: # noqa: BLE001 - unreachable / invalid robots -> allow-all
|
||||
return None
|
||||
|
||||
|
||||
def _allowed(rp: RobotFileParser | None, url: str) -> bool:
|
||||
if rp is None:
|
||||
return True
|
||||
try:
|
||||
return rp.can_fetch(_USER_AGENT, url)
|
||||
except Exception: # noqa: BLE001 - be permissive if the parser chokes on an entry
|
||||
return True
|
||||
|
||||
|
||||
async def crawl_site(
|
||||
start_url: str, max_pages: int = DEFAULT_MAX_PAGES, *,
|
||||
max_depth: int = DEFAULT_MAX_DEPTH, delay: float = DEFAULT_DELAY_SECONDS,
|
||||
) -> dict[str, str]:
|
||||
"""Same-domain BFS from ``start_url``, returning {url: extracted_text}.
|
||||
|
||||
Honors robots.txt (skips disallowed URLs, respects any Crawl-delay), waits ``delay`` seconds
|
||||
between fetches to stay polite, and stops at ``max_pages`` pages or ``max_depth`` link hops
|
||||
from the start (both clamped to hard caps). Unreachable pages are skipped, not fatal.
|
||||
"""
|
||||
from forge.services.knowledge import _strip_html
|
||||
from forge.util.http import shared_async_client
|
||||
from forge.util.ssrf import guarded_get
|
||||
|
||||
max_pages = max(1, min(int(max_pages or DEFAULT_MAX_PAGES), MAX_PAGES_CAP))
|
||||
max_depth = max(0, min(int(max_depth if max_depth is not None else DEFAULT_MAX_DEPTH), MAX_DEPTH_CAP))
|
||||
client = shared_async_client()
|
||||
rp = await _load_robots(client, start_url)
|
||||
robots_delay = rp.crawl_delay(_USER_AGENT) if rp else None
|
||||
delay = max(float(delay or 0.0), float(robots_delay or 0.0))
|
||||
|
||||
seen: set[str] = set()
|
||||
queue: list[tuple[str, int]] = [(start_url, 0)]
|
||||
pages: dict[str, str] = {}
|
||||
first = True
|
||||
while queue and len(pages) < max_pages:
|
||||
url, depth = queue.pop(0)
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
if not _allowed(rp, url):
|
||||
continue
|
||||
if not first and delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
first = False
|
||||
try:
|
||||
r = await guarded_get(client, url, timeout=20, follow_redirects=True)
|
||||
html = r.text
|
||||
except Exception: # noqa: BLE001 - skip unreachable pages
|
||||
continue
|
||||
pages[url] = _strip_html(html)
|
||||
if depth < max_depth:
|
||||
for link in extract_links(html, url):
|
||||
if link not in seen:
|
||||
queue.append((link, depth + 1))
|
||||
return pages
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Embedders. The default is a local, open-source model via fastembed (ONNX, no API
|
||||
key, no per-token cost) - it runs fully offline after a one-time model download. Set the
|
||||
project's rag embedding_model to 'openai:text-embedding-3-*' (+ a key) for a hosted model.
|
||||
|
||||
There is deliberately no toy/hash fallback: if no real embedder can be built we raise a
|
||||
clear error rather than silently returning meaningless vectors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from typing import Protocol
|
||||
|
||||
from forge.tracing.tracer import embedding_span
|
||||
|
||||
log = logging.getLogger("forge.embeddings")
|
||||
|
||||
|
||||
class Embedder(Protocol):
|
||||
name: str
|
||||
dim: int
|
||||
max_input_chars: int # safe char budget before the model truncates (see ingest clamp)
|
||||
|
||||
def embed(self, texts: list[str]) -> list[list[float]]: ...
|
||||
def embed_query(self, text: str) -> list[float]: ...
|
||||
async def aembed(self, texts: list[str]) -> list[list[float]]: ...
|
||||
async def aembed_query(self, text: str) -> list[float]: ...
|
||||
|
||||
|
||||
# Embedding dimensions per known model (a Chroma collection is fixed-dim; the
|
||||
# collection name is keyed by dim, so this must be right per model).
|
||||
_MODEL_DIMS = {
|
||||
"text-embedding-3-small": 1536,
|
||||
"text-embedding-3-large": 3072,
|
||||
"text-embedding-ada-002": 1536,
|
||||
# fastembed (local ONNX) models - keyed by their model id (== the embedder.name we
|
||||
# store on a source), so embedding_health can spot a dim change.
|
||||
"BAAI/bge-small-en-v1.5": 384,
|
||||
"BAAI/bge-base-en-v1.5": 768,
|
||||
"BAAI/bge-large-en-v1.5": 1024,
|
||||
}
|
||||
|
||||
# Default fastembed model when the ref is just "fastembed:" or unset (small, 384-dim, CPU-fast).
|
||||
_DEFAULT_FASTEMBED = "BAAI/bge-small-en-v1.5"
|
||||
|
||||
# Max input sequence LENGTH (tokens) per model. An embedder silently TRUNCATES anything longer,
|
||||
# so a chunk_size (in chars) that overflows this loses the tail of every chunk. Chunking is
|
||||
# char-based, so we convert with a deliberately conservative chars/token ratio (dense text can
|
||||
# be ~3-4 chars/token) to get a safe character budget - see `max_input_chars` + the ingest clamp.
|
||||
_MODEL_MAX_TOKENS = {
|
||||
"text-embedding-3-small": 8191, "text-embedding-3-large": 8191, "text-embedding-ada-002": 8191,
|
||||
"BAAI/bge-small-en-v1.5": 512, "BAAI/bge-base-en-v1.5": 512, "BAAI/bge-large-en-v1.5": 512,
|
||||
}
|
||||
_DEFAULT_MAX_TOKENS = 512 # conservative fallback for an unmapped model
|
||||
_CHARS_PER_TOKEN = 4 # rough English average; used only to derive a safe char budget from tokens
|
||||
|
||||
|
||||
def _est_tokens(texts: list[str]) -> int:
|
||||
"""Rough input-token estimate for pricing an embedding span (chars / ~4). Embedders don't
|
||||
return token counts, so this drives the span's cost via pricing; latency is always exact."""
|
||||
return sum(len(t or "") for t in texts) // _CHARS_PER_TOKEN
|
||||
|
||||
|
||||
def _max_input_chars(model_name: str) -> int:
|
||||
return _MODEL_MAX_TOKENS.get(model_name, _DEFAULT_MAX_TOKENS) * _CHARS_PER_TOKEN
|
||||
|
||||
# Every embedding dim a Chroma collection may have been created under: the known model dims
|
||||
# plus 256 (the removed hashed FakeEmbedder's legacy dim). delete/reingest sweep ALL of these
|
||||
# (see services.knowledge._dim_collections) so switching embedders can't leave orphaned
|
||||
# vectors behind - a stale FAQ would otherwise keep deflecting, a stale chunk keep surfacing.
|
||||
KNOWN_EMBEDDING_DIMS: frozenset[int] = frozenset({256, *_MODEL_DIMS.values()})
|
||||
|
||||
# Relevance floors calibrated to the DEFAULT local BGE embedder (repo audit + measured cosines):
|
||||
# BGE query/doc cosine for RELATED pairs ~0.75-0.86, UNRELATED ~0.38-0.52, so a 0.6 floor cleanly
|
||||
# separates them and lets a wildly off-topic query surface NOTHING (the grounded agent then says
|
||||
# it doesn't know instead of answering from the nearest chunk). Hosted models (e.g. OpenAI) sit
|
||||
# on a LOWER cosine scale - set a smaller min_score per project/node when using them.
|
||||
DEFAULT_MIN_SCORE = 0.6
|
||||
# Cross-encoder rerank floor on the sigmoid (0-1) scale. The default ms-marco reranker is sharply
|
||||
# bimodal (relevant ~1.0, irrelevant ~0.0 measured), so 0.3 drops off-topic while keeping
|
||||
# borderline-relevant passages. Applied ONLY when rerank is on (a different scale from cosine).
|
||||
DEFAULT_RERANK_MIN_SCORE = 0.3
|
||||
|
||||
|
||||
class _LCEmbedder:
|
||||
"""Adapter over a LangChain embeddings object (e.g. OpenAIEmbeddings)."""
|
||||
|
||||
def __init__(self, emb, model_name: str) -> None:
|
||||
self._e = emb
|
||||
self.name = model_name
|
||||
self.dim = _MODEL_DIMS.get(model_name, 1536)
|
||||
self.max_input_chars = _max_input_chars(model_name)
|
||||
|
||||
def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
return self._e.embed_documents(texts)
|
||||
|
||||
def embed_query(self, text: str) -> list[float]:
|
||||
return self._e.embed_query(text)
|
||||
|
||||
# Async variants keep network embed calls off the event loop's back (the sync
|
||||
# ones block the loop - and the SSE stream - for the whole round trip).
|
||||
async def aembed(self, texts: list[str]) -> list[list[float]]:
|
||||
with embedding_span(self.name, n_texts=len(texts), input_tokens=_est_tokens(texts)):
|
||||
return await self._e.aembed_documents(texts)
|
||||
|
||||
async def aembed_query(self, text: str) -> list[float]:
|
||||
with embedding_span(self.name, n_texts=1, input_tokens=_est_tokens([text])):
|
||||
return await self._e.aembed_query(text)
|
||||
|
||||
|
||||
class _FastEmbedEmbedder:
|
||||
"""Local, open-source embedder via fastembed (ONNX, no PyTorch, no API cost).
|
||||
|
||||
Model files download once to the HuggingFace cache on first use, then run fully
|
||||
offline on CPU. Output dim is probed once at construction (it drives the dim-keyed
|
||||
Chroma collection). Instances are cached in _EMBEDDER_CACHE so the ~model-load cost
|
||||
is paid once per process.
|
||||
"""
|
||||
|
||||
def __init__(self, model_name: str) -> None:
|
||||
from fastembed import TextEmbedding
|
||||
|
||||
from forge.config import settings
|
||||
|
||||
# A configured cache dir points at the model baked into the Docker image (offline,
|
||||
# no first-run download); None falls back to fastembed's own default temp cache.
|
||||
cache_dir = settings.fastembed_cache_dir or None
|
||||
self._model = TextEmbedding(model_name=model_name, cache_dir=cache_dir)
|
||||
self.name = model_name
|
||||
self.dim = len(next(iter(self._model.embed(["dim probe"]))))
|
||||
self.max_input_chars = _max_input_chars(model_name)
|
||||
|
||||
def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
# fastembed yields numpy float32 arrays; Chroma wants plain float lists. `tolist()`
|
||||
# returns native Python floats in one C-level call (far cheaper than a per-element
|
||||
# `float(x)` comprehension over the vector).
|
||||
return [v.tolist() for v in self._model.embed(list(texts))]
|
||||
|
||||
def embed_query(self, text: str) -> list[float]:
|
||||
return self.embed([text])[0]
|
||||
|
||||
# fastembed is synchronous CPU work; run it in a thread so a batch embed doesn't
|
||||
# block the event loop (mirrors the _LCEmbedder rationale for network calls).
|
||||
async def aembed(self, texts: list[str]) -> list[list[float]]:
|
||||
import asyncio
|
||||
|
||||
with embedding_span(self.name, n_texts=len(texts), input_tokens=_est_tokens(texts)):
|
||||
return await asyncio.to_thread(self.embed, texts)
|
||||
|
||||
async def aembed_query(self, text: str) -> list[float]:
|
||||
import asyncio
|
||||
|
||||
with embedding_span(self.name, n_texts=1, input_tokens=_est_tokens([text])):
|
||||
return await asyncio.to_thread(self.embed_query, text)
|
||||
|
||||
|
||||
# Provider embedder instances are expensive to construct (~1s measured on Windows:
|
||||
# the OpenAI SDK builds two httpx clients = two SSL contexts), so cache per
|
||||
# (model, key-fingerprint). The cache holds the client, not the key itself.
|
||||
_EMBEDDER_CACHE: dict[tuple[str, str], Embedder] = {}
|
||||
# Warn once per model when we fall back to the local default for a hosted-provider model
|
||||
# - the dim-keyed collection then won't match content indexed under the hosted model.
|
||||
_FALLBACK_WARNED: set[str] = set()
|
||||
|
||||
|
||||
def _key_fp(api_key: str | None) -> str:
|
||||
if not api_key:
|
||||
return "env"
|
||||
# Not a security hash: this only namespaces the in-process embedder cache by key, so the
|
||||
# plaintext key never becomes a dict key. usedforsecurity=False documents that intent.
|
||||
return hashlib.sha256(api_key.encode(), usedforsecurity=False).hexdigest()[:16]
|
||||
|
||||
|
||||
def _warn_once(model: str, msg: str) -> None:
|
||||
if model not in _FALLBACK_WARNED:
|
||||
_FALLBACK_WARNED.add(model)
|
||||
log.warning("resolve_embedder: %s (model=%r)", msg, model)
|
||||
|
||||
|
||||
def _resolve_fastembed(name: str) -> Embedder:
|
||||
"""Build (or reuse) a local fastembed embedder. Raises RuntimeError with an
|
||||
actionable message if fastembed / the model isn't available - there is no toy
|
||||
fallback, so callers surface a clear error instead of silently-wrong results."""
|
||||
cache_key = (f"fastembed:{name}", "fastembed")
|
||||
hit = _EMBEDDER_CACHE.get(cache_key)
|
||||
if hit is not None:
|
||||
return hit
|
||||
try:
|
||||
emb = _FastEmbedEmbedder(name)
|
||||
except Exception as e: # noqa: BLE001 - fastembed missing / model download failed
|
||||
raise RuntimeError(
|
||||
f"Local embedder {name!r} unavailable: {e}. Install the 'knowledge' extra "
|
||||
"(fastembed) and ensure the model can be downloaded, or set an OpenAI embedding "
|
||||
"model + API key in project settings."
|
||||
) from e
|
||||
_EMBEDDER_CACHE[cache_key] = emb
|
||||
return emb
|
||||
|
||||
|
||||
def resolve_embedder(model: str | None = None, api_key: str | None = None) -> Embedder:
|
||||
"""Return an embedder for the given model ref + (project) key.
|
||||
|
||||
Default (unset or 'fastembed:<model>') is a local open-source ONNX embedder - no key,
|
||||
no API cost, offline after a one-time model download. 'openai:text-embedding-3-*' uses
|
||||
a hosted OpenAI model when a key resolves (per-project key first, then OPENAI_API_KEY);
|
||||
without a key, or on any construction failure, we fall back to the local default and
|
||||
warn once. A Chroma collection is fixed-dim, so we key it by `embedder.dim` (switching
|
||||
models needs a re-embed). Instances are cached.
|
||||
"""
|
||||
# Local open-source default (also the ':'-only or unset ref).
|
||||
if not model or model.startswith("fastembed:"):
|
||||
name = model.split(":", 1)[1].strip() if (model and ":" in model) else ""
|
||||
return _resolve_fastembed(name or _DEFAULT_FASTEMBED)
|
||||
|
||||
if model.startswith("openai:"):
|
||||
if api_key or os.environ.get("OPENAI_API_KEY"):
|
||||
cache_key = (model, _key_fp(api_key))
|
||||
hit = _EMBEDDER_CACHE.get(cache_key)
|
||||
if hit is not None:
|
||||
return hit
|
||||
try:
|
||||
from langchain_openai import OpenAIEmbeddings
|
||||
|
||||
name = model.split(":", 1)[1]
|
||||
kwargs: dict = {"model": name}
|
||||
if api_key:
|
||||
kwargs["api_key"] = api_key
|
||||
emb = _LCEmbedder(OpenAIEmbeddings(**kwargs), name)
|
||||
_EMBEDDER_CACHE[cache_key] = emb
|
||||
return emb
|
||||
except Exception: # noqa: BLE001 - fall back to the local default (but make it visible)
|
||||
_warn_once(model, "could not construct OpenAIEmbeddings; falling back to local fastembed (dim differs - re-embed)")
|
||||
return _resolve_fastembed(_DEFAULT_FASTEMBED)
|
||||
# A hosted model was requested but no key resolved. Falling back to the local
|
||||
# default indexes/queries a DIFFERENT (dim-keyed) collection than the hosted model
|
||||
# would - the dim-flip trap that makes RAG/Q&A go quietly empty. Make it loud (once).
|
||||
_warn_once(model, "no OpenAI API key resolved; falling back to local fastembed (dim differs - re-embed)")
|
||||
return _resolve_fastembed(_DEFAULT_FASTEMBED)
|
||||
|
||||
# Unrecognized provider prefix -> local default rather than a hard failure.
|
||||
_warn_once(model, "unrecognized embedding model; falling back to local fastembed")
|
||||
return _resolve_fastembed(_DEFAULT_FASTEMBED)
|
||||
|
||||
|
||||
def cosine(a: list[float], b: list[float]) -> float:
|
||||
if not a or not b or len(a) != len(b):
|
||||
return 0.0
|
||||
dot = sum(x * y for x, y in zip(a, b, strict=False))
|
||||
na = math.sqrt(sum(x * x for x in a)) or 1.0
|
||||
nb = math.sqrt(sum(x * x for x in b)) or 1.0
|
||||
return dot / (na * nb)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Hybrid retrieval helpers: BM25 lexical ranking + Reciprocal Rank Fusion (RRF).
|
||||
|
||||
Dense vectors catch paraphrase ("how long for a refund" ~ "return processing time");
|
||||
BM25 catches exact/rare terms vectors blur (error codes, SKUs, proper nouns). RRF
|
||||
combines the two ranked lists by *position*, so their very different score scales
|
||||
(cosine ~[0,1] vs unbounded BM25) never have to be normalized against each other.
|
||||
|
||||
All pure functions + graceful degradation: if rank_bm25 isn't installed, bm25_rank
|
||||
returns [] and the caller falls back to vector-only - hybrid never hard-fails.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_TOKEN = re.compile(r"[a-z0-9]+")
|
||||
_RRF_K = 60 # standard RRF damping constant (Cormack et al.)
|
||||
|
||||
|
||||
def tokenize(text: str) -> list[str]:
|
||||
return _TOKEN.findall((text or "").lower())
|
||||
|
||||
|
||||
def build_bm25(docs: list[tuple[str, str]]) -> tuple | None:
|
||||
"""Build a REUSABLE BM25 index over (id, text) docs -> (bm25, ids), or None when
|
||||
rank_bm25 is absent / the corpus is empty / nothing tokenizes. Split out from
|
||||
``bm25_rank`` so the store can cache the index (the expensive part) per corpus version
|
||||
and re-run only the cheap per-query scoring (see store._build_lexical_index)."""
|
||||
if not docs:
|
||||
return None
|
||||
try:
|
||||
from rank_bm25 import BM25Okapi
|
||||
except Exception: # noqa: BLE001 - knowledge extra not installed -> vector-only
|
||||
return None
|
||||
tokenized = [tokenize(t) for _, t in docs]
|
||||
if not any(tokenized):
|
||||
return None
|
||||
return BM25Okapi(tokenized), [d[0] for d in docs]
|
||||
|
||||
|
||||
def bm25_scores(index: tuple | None, query: str) -> list[str]:
|
||||
"""Score a prebuilt index (from ``build_bm25``) against ``query``; ids best-first,
|
||||
positives only. [] when the index is empty or the query has no usable tokens."""
|
||||
if not index:
|
||||
return []
|
||||
bm25, ids = index
|
||||
if bm25 is None or not ids:
|
||||
return []
|
||||
q = tokenize(query)
|
||||
if not q:
|
||||
return []
|
||||
scores = bm25.get_scores(q)
|
||||
ranked = sorted(range(len(ids)), key=lambda i: scores[i], reverse=True)
|
||||
return [ids[i] for i in ranked if scores[i] > 0]
|
||||
|
||||
|
||||
def bm25_rank(query: str, docs: list[tuple[str, str]]) -> list[str]:
|
||||
"""Rank (id, text) candidates by BM25 against ``query``; ids best-first, positives
|
||||
only. Returns [] when rank_bm25 is absent, the corpus is empty, or nothing matches.
|
||||
Thin wrapper over build_bm25 + bm25_scores (kept for the one-shot / test call sites)."""
|
||||
return bm25_scores(build_bm25(docs), query)
|
||||
|
||||
|
||||
def rrf_fuse(*ranked_lists: list[str], k: int = _RRF_K) -> dict[str, float]:
|
||||
"""Reciprocal Rank Fusion over any number of ranked id lists -> {id: fused_score}."""
|
||||
fused: dict[str, float] = {}
|
||||
for ids in ranked_lists:
|
||||
for rank, _id in enumerate(ids):
|
||||
fused[_id] = fused.get(_id, 0.0) + 1.0 / (k + rank + 1)
|
||||
return fused
|
||||
@@ -0,0 +1,309 @@
|
||||
"""pgvector-backed EmbeddingStore - the production alternative to the embedded ChromaStore.
|
||||
|
||||
Selected by `settings.vector_backend == "pgvector"` via `store.make_store()`. Unlike Chroma
|
||||
(an on-disk index a single process owns), every worker connects to the same Postgres, so
|
||||
vectors are shared across a horizontally-scaled deployment.
|
||||
|
||||
Design
|
||||
------
|
||||
- One table (`kb_vectors`) holds every collection's rows; the `collection` column carries the
|
||||
dim-keyed name (e.g. forge_kb_384) the caller already uses, so different embedders never mix
|
||||
in a distance comparison (each query is scoped to one collection => one dim).
|
||||
- The `embedding` column is an unmodified `vector` (no fixed dimension) so collections of
|
||||
different dims coexist in the table. Searches are exact (a scoped sequential scan ordered by
|
||||
cosine distance) - correct and simple; an ANN index (HNSW) is a per-dim follow-up.
|
||||
- Chunk metadata is stored as JSONB and the Chroma-style where-dicts the callers build
|
||||
($and / $eq / $in over metadata fields) are translated to SQL predicates over it, so the
|
||||
store is a drop-in for ChromaStore without changing any call site.
|
||||
|
||||
Connections are opened per operation (psycopg v3, synchronous - store methods are already
|
||||
called off the event loop in a threadpool), which is the thread-safe choice and needs no
|
||||
connection-pool dependency. Requires Postgres with the `vector` extension available.
|
||||
|
||||
Interface parity with ChromaStore: upsert / query / query_where / hybrid_query / dump /
|
||||
delete_ids / delete_by_source / delete_where / count / count_where / list_docs / get_texts /
|
||||
ids_where.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
|
||||
from forge.config import settings
|
||||
from forge.knowledge.store import _CORPUS_CAP, Hit, _bump_version, _hybrid_fuse, _where
|
||||
|
||||
_TABLE = "kb_vectors"
|
||||
# DSNs whose schema (extension + table + indexes) has been ensured this process.
|
||||
_INITIALIZED: set[str] = set()
|
||||
_INIT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _sync_dsn() -> str:
|
||||
"""A libpq-compatible DSN for psycopg from the app's async database_url (drop the
|
||||
SQLAlchemy driver suffix). pgvector requires Postgres; a sqlite url is a misconfiguration."""
|
||||
url = settings.database_url
|
||||
if url.startswith("sqlite"):
|
||||
raise RuntimeError(
|
||||
"vector_backend='pgvector' requires a Postgres database_url; got a sqlite url."
|
||||
)
|
||||
return url.replace("+asyncpg", "").replace("+psycopg", "")
|
||||
|
||||
|
||||
def _to_vector_literal(embedding) -> str:
|
||||
# pgvector accepts the text form '[v1,v2,...]'; we cast it with ::vector in SQL.
|
||||
return "[" + ",".join(repr(float(x)) for x in embedding) + "]"
|
||||
|
||||
|
||||
def _translate_where(where: dict | None, params: list) -> str:
|
||||
"""Translate the Chroma where-dict subset Forge builds ($and/$or of {field: {$eq|$in}},
|
||||
plus the {field: value} shorthand) into a SQL predicate over the JSONB `metadata` column.
|
||||
Field names AND values are bound as parameters (never string-formatted), so this is
|
||||
injection-safe even though the fields are internal. Unsupported operators fail closed."""
|
||||
if not where:
|
||||
return "TRUE"
|
||||
if "$and" in where:
|
||||
return "(" + " AND ".join(_translate_where(c, params) for c in where["$and"]) + ")"
|
||||
if "$or" in where:
|
||||
return "(" + " OR ".join(_translate_where(c, params) for c in where["$or"]) + ")"
|
||||
clauses: list[str] = []
|
||||
for field, cond in where.items():
|
||||
cond = cond if isinstance(cond, dict) else {"$eq": cond}
|
||||
for op, val in cond.items():
|
||||
if op == "$eq":
|
||||
params.extend([field, str(val)])
|
||||
clauses.append("(metadata->>%s) = %s")
|
||||
elif op == "$in":
|
||||
params.extend([field, [str(v) for v in (val or [])]])
|
||||
clauses.append("(metadata->>%s) = ANY(%s)")
|
||||
else:
|
||||
raise ValueError(f"pgvector store: unsupported where operator {op!r}")
|
||||
return "(" + " AND ".join(clauses) + ")" if len(clauses) > 1 else (clauses[0] if clauses else "TRUE")
|
||||
|
||||
|
||||
class PgVectorStore:
|
||||
def __init__(self, collection: str = "forge_kb") -> None:
|
||||
self._collection = collection
|
||||
self._dsn = _sync_dsn()
|
||||
# `_key` namespaces the shared BM25 cache (store._hybrid_fuse) per DSN + collection.
|
||||
self._key = (self._dsn, collection)
|
||||
self._ensure_schema()
|
||||
|
||||
# --- connection / schema ------------------------------------------------------------
|
||||
def _connect(self):
|
||||
import psycopg
|
||||
|
||||
return psycopg.connect(self._dsn)
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
if self._dsn in _INITIALIZED:
|
||||
return
|
||||
with _INIT_LOCK:
|
||||
if self._dsn in _INITIALIZED:
|
||||
return
|
||||
with self._connect() as conn, conn.cursor() as cur:
|
||||
# The extension may already be installed by an admin without CREATE privilege
|
||||
# for the app role; ignore a permission failure and rely on the type existing.
|
||||
try:
|
||||
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
conn.commit()
|
||||
except Exception: # noqa: BLE001 - extension pre-provisioned / no privilege
|
||||
conn.rollback()
|
||||
cur.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {_TABLE} (
|
||||
collection text NOT NULL,
|
||||
id text NOT NULL,
|
||||
document text,
|
||||
metadata jsonb NOT NULL DEFAULT '{{}}'::jsonb,
|
||||
embedding vector NOT NULL,
|
||||
PRIMARY KEY (collection, id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS ix_{_TABLE}_metadata ON {_TABLE} USING gin (metadata)"
|
||||
)
|
||||
conn.commit()
|
||||
_INITIALIZED.add(self._dsn)
|
||||
|
||||
def _scope(self, where: dict | None, params: list) -> str:
|
||||
"""Collection scope + the translated metadata predicate (collection first so a scan
|
||||
only ever compares vectors of one dimension)."""
|
||||
params.append(self._collection)
|
||||
return "collection = %s AND " + _translate_where(where, params)
|
||||
|
||||
# --- writes -------------------------------------------------------------------------
|
||||
def upsert(self, *, ids, embeddings, documents, metadatas) -> None:
|
||||
if not ids:
|
||||
return
|
||||
rows = [
|
||||
(self._collection, _id, documents[i], json.dumps(metadatas[i] or {}),
|
||||
_to_vector_literal(embeddings[i]))
|
||||
for i, _id in enumerate(ids)
|
||||
]
|
||||
with self._connect() as conn, conn.cursor() as cur:
|
||||
cur.executemany(
|
||||
f"""
|
||||
INSERT INTO {_TABLE} (collection, id, document, metadata, embedding)
|
||||
VALUES (%s, %s, %s, %s::jsonb, %s::vector)
|
||||
ON CONFLICT (collection, id) DO UPDATE SET
|
||||
document = EXCLUDED.document,
|
||||
metadata = EXCLUDED.metadata,
|
||||
embedding = EXCLUDED.embedding
|
||||
""",
|
||||
rows,
|
||||
)
|
||||
conn.commit()
|
||||
_bump_version(self._key)
|
||||
|
||||
def delete_ids(self, ids: list[str]) -> None:
|
||||
if not ids:
|
||||
return
|
||||
with self._connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"DELETE FROM {_TABLE} WHERE collection = %s AND id = ANY(%s)",
|
||||
[self._collection, list(ids)],
|
||||
)
|
||||
conn.commit()
|
||||
_bump_version(self._key)
|
||||
|
||||
def delete_by_source(self, source_id: str, *, tenant_id: str | None = None, project_id: str | None = None) -> None:
|
||||
clauses: list[dict] = [{"source_id": {"$eq": source_id}}]
|
||||
if tenant_id:
|
||||
clauses.append({"tenant_id": {"$eq": tenant_id}})
|
||||
if project_id:
|
||||
clauses.append({"project_id": {"$eq": project_id}})
|
||||
self.delete_where({"$and": clauses} if len(clauses) > 1 else clauses[0])
|
||||
|
||||
def delete_where(self, where: dict) -> None:
|
||||
params: list = []
|
||||
scope = self._scope(where, params)
|
||||
with self._connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(f"DELETE FROM {_TABLE} WHERE {scope}", params)
|
||||
conn.commit()
|
||||
_bump_version(self._key)
|
||||
|
||||
# --- reads --------------------------------------------------------------------------
|
||||
def query(self, *, embedding, tenant_id, project_id, top_k=5, source_ids=None) -> list[Hit]:
|
||||
return self.query_where(
|
||||
embedding=embedding, where=_where(tenant_id, project_id, source_ids), top_k=top_k
|
||||
)
|
||||
|
||||
def query_where(self, *, embedding, where: dict, top_k: int = 5) -> list[Hit]:
|
||||
qvec = _to_vector_literal(embedding)
|
||||
params: list = [qvec] # SELECT score term
|
||||
scope = self._scope(where, params)
|
||||
params.extend([qvec, top_k]) # ORDER BY term + LIMIT
|
||||
sql = (
|
||||
f"SELECT id, document, 1 - (embedding <=> %s::vector) AS score, metadata "
|
||||
f"FROM {_TABLE} WHERE {scope} ORDER BY embedding <=> %s::vector LIMIT %s"
|
||||
)
|
||||
with self._connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
return [
|
||||
Hit(id=r[0], text=r[1] or "", score=float(r[2]), metadata=r[3] or {})
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
|
||||
def _get_documents(self, where: dict, limit: int | None = None) -> list[Hit]:
|
||||
"""All stored chunks matching `where` (no vector query) - the corpus a lexical index
|
||||
is built over. score is 0.0 (unranked); `limit` caps the scan."""
|
||||
params: list = []
|
||||
scope = self._scope(where, params)
|
||||
sql = f"SELECT id, document, metadata FROM {_TABLE} WHERE {scope}"
|
||||
if limit:
|
||||
sql += " LIMIT %s"
|
||||
params.append(limit)
|
||||
with self._connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
return [Hit(id=r[0], text=r[1] or "", score=0.0, metadata=r[2] or {}) for r in cur.fetchall()]
|
||||
|
||||
def hybrid_query(
|
||||
self, *, embedding, query: str, tenant_id, project_id, top_k=5, source_ids=None,
|
||||
candidate_pool: int | None = None, corpus_cap: int = _CORPUS_CAP,
|
||||
) -> list[Hit]:
|
||||
"""Dense+BM25 RRF fusion, identical semantics to ChromaStore (see store._hybrid_fuse)."""
|
||||
return _hybrid_fuse(
|
||||
self, embedding=embedding, query=query, where=_where(tenant_id, project_id, source_ids),
|
||||
top_k=top_k, candidate_pool=candidate_pool, corpus_cap=corpus_cap,
|
||||
)
|
||||
|
||||
def dump(self, where: dict, limit: int | None = None, *, ids: list[str] | None = None) -> dict:
|
||||
"""Raw rows INCLUDING embedding vectors (parsed back to float lists) - the input to the
|
||||
chunk map's dimensionality reduction. Matches ChromaStore.dump's shape."""
|
||||
if ids:
|
||||
params: list = [self._collection, list(ids)]
|
||||
sql = f"SELECT id, document, metadata, embedding::text FROM {_TABLE} WHERE collection = %s AND id = ANY(%s)"
|
||||
else:
|
||||
params = []
|
||||
scope = self._scope(where, params)
|
||||
sql = f"SELECT id, document, metadata, embedding::text FROM {_TABLE} WHERE {scope}"
|
||||
if limit:
|
||||
sql += " LIMIT %s"
|
||||
params.append(limit)
|
||||
try:
|
||||
with self._connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
out = {"ids": [], "documents": [], "metadatas": [], "embeddings": []}
|
||||
for r in cur.fetchall():
|
||||
out["ids"].append(r[0])
|
||||
out["documents"].append(r[1] or "")
|
||||
out["metadatas"].append(r[2] or {})
|
||||
out["embeddings"].append(json.loads(r[3]) if r[3] else [])
|
||||
return out
|
||||
except Exception: # noqa: BLE001 - table empty / not ready
|
||||
return {"ids": [], "documents": [], "metadatas": [], "embeddings": []}
|
||||
|
||||
def count(self, tenant_id: str, project_id: str) -> int:
|
||||
return self.count_where(_where(tenant_id, project_id, None))
|
||||
|
||||
def count_where(self, where: dict) -> int:
|
||||
params: list = []
|
||||
scope = self._scope(where, params)
|
||||
try:
|
||||
with self._connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(f"SELECT count(*) FROM {_TABLE} WHERE {scope}", params)
|
||||
return int(cur.fetchone()[0])
|
||||
except Exception: # noqa: BLE001
|
||||
return 0
|
||||
|
||||
def list_docs(self, where: dict) -> dict:
|
||||
"""ids + documents + metadatas (NO embeddings) for `where`."""
|
||||
rows = self._get_documents(where)
|
||||
return {
|
||||
"ids": [h.id for h in rows],
|
||||
"documents": [h.text for h in rows],
|
||||
"metadatas": [h.metadata for h in rows],
|
||||
}
|
||||
|
||||
def get_texts(self, ids: list[str], where: dict) -> dict:
|
||||
"""Documents + metadatas for specific `ids`, ADDITIONALLY constrained by `where` - so a
|
||||
caller-supplied id can't read a row outside its tenant/project."""
|
||||
if not ids:
|
||||
return {"ids": [], "documents": [], "metadatas": []}
|
||||
params: list = []
|
||||
scope = self._scope(where, params)
|
||||
params.append(list(ids))
|
||||
sql = f"SELECT id, document, metadata FROM {_TABLE} WHERE {scope} AND id = ANY(%s)"
|
||||
try:
|
||||
with self._connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
except Exception: # noqa: BLE001
|
||||
return {"ids": [], "documents": [], "metadatas": []}
|
||||
return {
|
||||
"ids": [r[0] for r in rows],
|
||||
"documents": [r[1] or "" for r in rows],
|
||||
"metadatas": [r[2] or {} for r in rows],
|
||||
}
|
||||
|
||||
def ids_where(self, where: dict) -> list[str]:
|
||||
params: list = []
|
||||
scope = self._scope(where, params)
|
||||
try:
|
||||
with self._connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(f"SELECT id FROM {_TABLE} WHERE {scope}", params)
|
||||
return [r[0] for r in cur.fetchall()]
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Two-stage retrieval: a local cross-encoder re-ranker (stage 2).
|
||||
|
||||
Vector/hybrid search (stage 1) is fast but coarse - it scores every candidate against the
|
||||
query independently. A cross-encoder reads the query AND a candidate *together*, so it judges
|
||||
relevance far more accurately - at a cost, which is why it only ever runs over a small
|
||||
shortlist (top-N) that stage 1 already narrowed down.
|
||||
|
||||
Fully local + offline, no new dependency: it reuses ``fastembed`` (already the default
|
||||
embedder backend) via its ``TextCrossEncoder`` - ONNX on CPU, model files download once to the
|
||||
same HuggingFace cache the embedders use. Like ``bm25_rank`` in hybrid.py, this NEVER hard-fails:
|
||||
if the model can't load (knowledge extra absent / offline first run / bad model id) the input
|
||||
order is returned unchanged and a warning is logged once. Re-ranking is opt-in per retrieval
|
||||
node (or the search debugger); default retrieval is unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import replace
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from forge.knowledge.store import Hit
|
||||
|
||||
log = logging.getLogger("forge.rerank")
|
||||
|
||||
# Small, CPU-fast cross-encoder (~80 MB). BAAI/bge-reranker-base (~1 GB) is a heavier, higher-
|
||||
# quality alternative a project can opt into via rag_defaults.reranker_model.
|
||||
DEFAULT_RERANKER = "Xenova/ms-marco-MiniLM-L-6-v2"
|
||||
|
||||
# Warn once per model when a reranker can't be built, so a missing model degrades quietly to
|
||||
# stage-1 order instead of spamming logs on every query.
|
||||
_FALLBACK_WARNED: set[str] = set()
|
||||
|
||||
|
||||
class Reranker(Protocol):
|
||||
name: str
|
||||
|
||||
def scores(self, query: str, docs: list[str]) -> list[float]: ...
|
||||
|
||||
|
||||
def _sigmoid(x: float) -> float:
|
||||
# Cross-encoder outputs are unbounded logits; squash to (0,1) so a re-ranked Hit.score
|
||||
# keeps the same 0..1 scale as cosine/fused scores and a downstream min_score still filters.
|
||||
if x >= 0:
|
||||
return 1.0 / (1.0 + math.exp(-x))
|
||||
ex = math.exp(x)
|
||||
return ex / (1.0 + ex)
|
||||
|
||||
|
||||
class _FastEmbedReranker:
|
||||
"""Adapter over fastembed's TextCrossEncoder (ONNX cross-encoder, CPU, offline)."""
|
||||
|
||||
def __init__(self, model_name: str) -> None:
|
||||
from fastembed.rerank.cross_encoder import TextCrossEncoder
|
||||
|
||||
from forge.config import settings
|
||||
|
||||
# Reuse the same baked/offline model cache the embedders use (falls back to fastembed's
|
||||
# own default temp cache when unset).
|
||||
cache_dir = settings.fastembed_cache_dir or None
|
||||
self._model = TextCrossEncoder(model_name=model_name, cache_dir=cache_dir)
|
||||
self.name = model_name
|
||||
|
||||
def scores(self, query: str, docs: list[str]) -> list[float]:
|
||||
# TextCrossEncoder.rerank yields one raw score per doc, in input order.
|
||||
return [float(s) for s in self._model.rerank(query, list(docs))]
|
||||
|
||||
|
||||
# Rerankers are expensive to construct (ONNX model load); cache one per model id process-wide,
|
||||
# mirroring the embedder cache in embeddings.py.
|
||||
_RERANKER_CACHE: dict[str, Reranker] = {}
|
||||
|
||||
|
||||
def _normalize_model(model: str | None) -> str:
|
||||
"""Accept a bare id ('Xenova/...') or a 'fastembed:<id>' ref (matching the embedding_model
|
||||
convention); '' / None / ':'-only -> the default reranker."""
|
||||
if not model:
|
||||
return DEFAULT_RERANKER
|
||||
m = model.strip()
|
||||
if m.startswith("fastembed:"):
|
||||
m = m.split(":", 1)[1].strip()
|
||||
return m or DEFAULT_RERANKER
|
||||
|
||||
|
||||
def resolve_reranker(model: str | None = None) -> Reranker | None:
|
||||
"""Build (or reuse) a local cross-encoder re-ranker. Returns None (never raises) if the
|
||||
model can't be constructed, so callers fall back to stage-1 order."""
|
||||
name = _normalize_model(model)
|
||||
hit = _RERANKER_CACHE.get(name)
|
||||
if hit is not None:
|
||||
return hit
|
||||
try:
|
||||
rr = _FastEmbedReranker(name)
|
||||
except Exception as e: # noqa: BLE001 - fastembed/model unavailable -> no rerank
|
||||
if name not in _FALLBACK_WARNED:
|
||||
_FALLBACK_WARNED.add(name)
|
||||
log.warning("resolve_reranker: %r unavailable (%s); using stage-1 order", name, e)
|
||||
return None
|
||||
_RERANKER_CACHE[name] = rr
|
||||
return rr
|
||||
|
||||
|
||||
def rerank_hits(query: str, hits: list[Hit], *, top_k: int, model: str | None = None) -> list[Hit]:
|
||||
"""Re-order ``hits`` by cross-encoder relevance to ``query`` and keep the top ``top_k``.
|
||||
|
||||
Each returned Hit's ``score`` is the sigmoid of the cross-encoder logit (0..1), so
|
||||
min_score filtering downstream still works. Degrades to ``hits[:top_k]`` (unchanged order)
|
||||
when there is nothing to do or the reranker can't be built - never hard-fails.
|
||||
"""
|
||||
if not hits or not (query or "").strip():
|
||||
return hits[:top_k]
|
||||
reranker = resolve_reranker(model)
|
||||
if reranker is None:
|
||||
return hits[:top_k]
|
||||
try:
|
||||
raw = reranker.scores(query, [h.text for h in hits])
|
||||
except Exception: # noqa: BLE001 - inference failure -> stage-1 order
|
||||
log.warning("rerank_hits: cross-encoder inference failed; using stage-1 order", exc_info=True)
|
||||
return hits[:top_k]
|
||||
# A score-count mismatch would let zip() silently drop the unpaired tail hits; treat it as a
|
||||
# failure and fall back to stage-1 order rather than returning a truncated/misaligned set.
|
||||
if len(raw) != len(hits):
|
||||
log.warning("rerank_hits: got %d scores for %d hits; using stage-1 order", len(raw), len(hits))
|
||||
return hits[:top_k]
|
||||
scored = sorted(
|
||||
(replace(h, score=round(_sigmoid(s), 4)) for h, s in zip(hits, raw, strict=True)),
|
||||
key=lambda h: h.score,
|
||||
reverse=True,
|
||||
)
|
||||
return scored[:top_k]
|
||||
|
||||
|
||||
async def arerank_hits(query: str, hits: list[Hit], *, top_k: int, model: str | None = None) -> list[Hit]:
|
||||
"""Async wrapper: run the (CPU-bound) cross-encoder off the event loop."""
|
||||
import asyncio
|
||||
|
||||
return await asyncio.to_thread(rerank_hits, query, hits, top_k=top_k, model=model)
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Pluggable chunking strategies for knowledge ingestion.
|
||||
|
||||
Four strategies, all targeting ~chunk_size characters with overlap:
|
||||
|
||||
- ``recursive`` (default): LangChain's RecursiveCharacterTextSplitter when the
|
||||
``knowledge`` extra is installed, falling back to the dependency-free recursive
|
||||
character splitter below. Best general-purpose choice.
|
||||
- ``section``: split on Markdown headers / heading lines so each chunk is a whole
|
||||
section. Meaningful when the document is well structured (docs, wikis, crawled
|
||||
pages which are concatenated under ``# {url}`` headers). Oversized sections are
|
||||
recursively sub-split so a chunk never blows past chunk_size.
|
||||
- ``sentence``: split on sentence boundaries (abbreviation-aware regex) then pack
|
||||
sentences into chunks with sentence-level overlap. Meaningful when meaning lives
|
||||
at the sentence level (FAQs, transcripts, prose).
|
||||
- ``semantic``: split where the *meaning* shifts. Embeds each sentence and cuts at
|
||||
the largest drops in sentence-to-sentence similarity (percentile break-points), so
|
||||
a chunk stays on one topic. Needs an ``embed_fn`` (the ingest pipeline injects the
|
||||
project embedder) - without one, or for very short text, it falls back to recursive.
|
||||
It is the only strategy that isn't purely lexical; the others need no model.
|
||||
|
||||
``chunk_text`` is the single dispatch entrypoint; ``split_text`` is kept as the
|
||||
pure-Python recursive splitter (public API + the universal fallback).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
|
||||
# A callable that embeds a batch of texts -> one vector (list of floats) each. Matches the
|
||||
# Embedder.embed signature in knowledge/embeddings.py, so ingest can pass `embedder.embed`.
|
||||
EmbedFn = Callable[[list[str]], list]
|
||||
|
||||
# Canonical strategy names (kept in sync with packages/schemas/forge/project.json
|
||||
# rag_defaults.chunking_strategy and KbSourceCreate.chunking_strategy).
|
||||
CHUNK_STRATEGIES = ("recursive", "section", "sentence", "semantic")
|
||||
DEFAULT_STRATEGY = "recursive"
|
||||
|
||||
# Break sentences into a new chunk when their similarity drop is in the top (100-N)% of drops.
|
||||
# 95 => cut only at the sharpest ~5% of topic shifts (few, clean boundaries).
|
||||
_SEMANTIC_BREAKPOINT_PERCENTILE = 95.0
|
||||
|
||||
_SEPARATORS = ["\n\n", "\n", ". ", " "]
|
||||
|
||||
# Markdown ATX headers (# .. ######) anchor section boundaries.
|
||||
_HEADER_RE = re.compile(r"^#{1,6}[ \t]+\S.*$", re.MULTILINE)
|
||||
|
||||
# Sentence boundary: end punctuation followed by whitespace then an opener
|
||||
# (capital letter, digit, or quote/paren). Lookarounds keep the delimiter attached
|
||||
# to the left sentence.
|
||||
_SENT_BOUNDARY = re.compile(r'(?<=[.!?])["\')\]]*\s+(?=[A-Z0-9"\'(\[])')
|
||||
|
||||
# Common abbreviations whose trailing period is NOT a sentence end. Compared
|
||||
# lowercased with the trailing dot stripped.
|
||||
_ABBREVIATIONS = frozenset({
|
||||
"mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "vs", "etc", "eg", "ie",
|
||||
"e.g", "i.e", "inc", "ltd", "co", "corp", "no", "fig", "al", "approx", "dept",
|
||||
"est", "u.s", "u.k", "a.m", "p.m", "vol", "pp",
|
||||
})
|
||||
|
||||
|
||||
def chunk_text(
|
||||
text: str, *, strategy: str = DEFAULT_STRATEGY, chunk_size: int = 1000, overlap: int = 200,
|
||||
embed_fn: EmbedFn | None = None,
|
||||
) -> list[str]:
|
||||
"""Split ``text`` using the named strategy. Unknown/empty strategy -> recursive.
|
||||
|
||||
``embed_fn`` is only used by the ``semantic`` strategy (the ingest pipeline passes the
|
||||
project embedder). Every other strategy ignores it and stays fully offline/lexical.
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
strategy = (strategy or DEFAULT_STRATEGY).strip().lower()
|
||||
if strategy == "section":
|
||||
return _split_sections(text, chunk_size, overlap)
|
||||
if strategy == "sentence":
|
||||
return _split_sentences(text, chunk_size, overlap)
|
||||
if strategy == "semantic":
|
||||
return _split_semantic(text, chunk_size, overlap, embed_fn)
|
||||
return _split_recursive(text, chunk_size, overlap)
|
||||
|
||||
|
||||
def split_text(text: str, chunk_size: int = 1000, overlap: int = 200) -> list[str]:
|
||||
"""Dependency-free recursive character splitter (~chunk_size with overlap).
|
||||
|
||||
Kept as a stable public API and as the universal fallback for every strategy.
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
if len(text) <= chunk_size:
|
||||
return [text] if text else []
|
||||
|
||||
# Find the best separator that produces pieces, then greedily pack into chunks.
|
||||
pieces = _split_on_separators(text, _SEPARATORS, chunk_size)
|
||||
chunks: list[str] = []
|
||||
cur = ""
|
||||
for p in pieces:
|
||||
if len(cur) + len(p) + 1 <= chunk_size:
|
||||
cur = f"{cur} {p}".strip() if cur else p
|
||||
else:
|
||||
if cur:
|
||||
chunks.append(cur)
|
||||
# carry overlap from the tail of the previous chunk
|
||||
tail = cur[-overlap:] if overlap and cur else ""
|
||||
cur = (f"{tail} {p}").strip() if tail else p
|
||||
if cur:
|
||||
chunks.append(cur)
|
||||
return [c for c in chunks if c.strip()]
|
||||
|
||||
|
||||
def _split_on_separators(text: str, seps: list[str], chunk_size: int) -> list[str]:
|
||||
if len(text) <= chunk_size or not seps:
|
||||
return [text]
|
||||
sep = seps[0]
|
||||
parts = text.split(sep) if sep in text else [text]
|
||||
out: list[str] = []
|
||||
for part in parts:
|
||||
if len(part) <= chunk_size:
|
||||
out.append(part)
|
||||
else:
|
||||
out.extend(_split_on_separators(part, seps[1:], chunk_size))
|
||||
return out
|
||||
|
||||
|
||||
def _split_recursive(text: str, chunk_size: int, overlap: int) -> list[str]:
|
||||
"""Recursive-character strategy: prefer LangChain's splitter, else fall back."""
|
||||
try:
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
|
||||
splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=chunk_size, chunk_overlap=min(overlap, max(chunk_size - 1, 0))
|
||||
)
|
||||
chunks = [c.strip() for c in splitter.split_text(text) if c and c.strip()]
|
||||
if chunks:
|
||||
return chunks
|
||||
except Exception: # noqa: BLE001 - knowledge extra absent / splitter error -> fallback
|
||||
pass
|
||||
return split_text(text, chunk_size, overlap)
|
||||
|
||||
|
||||
def _split_sections(text: str, chunk_size: int, overlap: int) -> list[str]:
|
||||
"""Each Markdown section (header + body up to the next header) becomes a chunk;
|
||||
oversized sections are recursively sub-split. Falls back to recursive when the
|
||||
document has no headers to key off."""
|
||||
matches = list(_HEADER_RE.finditer(text))
|
||||
if not matches:
|
||||
return _split_recursive(text, chunk_size, overlap)
|
||||
|
||||
sections: list[str] = []
|
||||
# Any preamble before the first header is its own section.
|
||||
if matches[0].start() > 0:
|
||||
pre = text[: matches[0].start()].strip()
|
||||
if pre:
|
||||
sections.append(pre)
|
||||
for i, m in enumerate(matches):
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||
sec = text[m.start():end].strip()
|
||||
if sec:
|
||||
sections.append(sec)
|
||||
|
||||
chunks: list[str] = []
|
||||
for sec in sections:
|
||||
if len(sec) <= chunk_size:
|
||||
chunks.append(sec)
|
||||
else:
|
||||
chunks.extend(_split_recursive(sec, chunk_size, overlap))
|
||||
return [c for c in chunks if c.strip()]
|
||||
|
||||
|
||||
def _split_into_sentences(text: str) -> list[str]:
|
||||
raw = _SENT_BOUNDARY.split(text)
|
||||
sentences: list[str] = []
|
||||
for piece in raw:
|
||||
s = piece.strip()
|
||||
if not s:
|
||||
continue
|
||||
# Re-merge if the previous sentence ended on a known abbreviation (the
|
||||
# boundary regex split too eagerly after e.g. "Dr." or "U.S.").
|
||||
if sentences:
|
||||
last_word = re.split(r"\s+", sentences[-1])[-1].rstrip(".").lower()
|
||||
if last_word in _ABBREVIATIONS:
|
||||
sentences[-1] = f"{sentences[-1]} {s}"
|
||||
continue
|
||||
sentences.append(s)
|
||||
return sentences
|
||||
|
||||
|
||||
def _split_sentences(text: str, chunk_size: int, overlap: int) -> list[str]:
|
||||
"""Pack whole sentences into ~chunk_size chunks, carrying trailing sentences
|
||||
forward as overlap. A single over-long sentence is recursively sub-split."""
|
||||
sentences = _split_into_sentences(text)
|
||||
if not sentences:
|
||||
return _split_recursive(text, chunk_size, overlap)
|
||||
|
||||
chunks: list[str] = []
|
||||
cur: list[str] = []
|
||||
cur_len = 0
|
||||
for s in sentences:
|
||||
if len(s) > chunk_size:
|
||||
if cur:
|
||||
chunks.append(" ".join(cur))
|
||||
cur, cur_len = [], 0
|
||||
chunks.extend(_split_recursive(s, chunk_size, overlap))
|
||||
continue
|
||||
if cur and cur_len + len(s) + 1 > chunk_size:
|
||||
chunks.append(" ".join(cur))
|
||||
# Sentence-level overlap: carry the trailing sentences up to ~overlap chars.
|
||||
carry: list[str] = []
|
||||
carry_len = 0
|
||||
for prev in reversed(cur):
|
||||
if carry_len + len(prev) + 1 > overlap:
|
||||
break
|
||||
carry.insert(0, prev)
|
||||
carry_len += len(prev) + 1
|
||||
cur, cur_len = carry, carry_len
|
||||
cur.append(s)
|
||||
cur_len += len(s) + 1
|
||||
if cur:
|
||||
chunks.append(" ".join(cur))
|
||||
return [c for c in chunks if c.strip()]
|
||||
|
||||
|
||||
def _percentile(sorted_vals: list[float], pct: float) -> float:
|
||||
"""Linear-interpolated percentile over an already-sorted list (no numpy dependency)."""
|
||||
if not sorted_vals:
|
||||
return 0.0
|
||||
if len(sorted_vals) == 1:
|
||||
return sorted_vals[0]
|
||||
rank = (pct / 100.0) * (len(sorted_vals) - 1)
|
||||
lo = int(rank)
|
||||
hi = min(lo + 1, len(sorted_vals) - 1)
|
||||
return sorted_vals[lo] + (sorted_vals[hi] - sorted_vals[lo]) * (rank - lo)
|
||||
|
||||
|
||||
def _split_semantic(
|
||||
text: str, chunk_size: int, overlap: int, embed_fn: EmbedFn | None
|
||||
) -> list[str]:
|
||||
"""Split on meaning-drift: embed each sentence, then cut between sentences whose
|
||||
similarity drop is among the sharpest (strictly above the break-point percentile). A single
|
||||
topic stays in one chunk; a topic shift starts a new one. Segments larger than chunk_size are
|
||||
recursively sub-split. Falls back to recursive when there's no embedder, too few
|
||||
sentences to compare, or embedding fails - so it can never hard-fail an ingest."""
|
||||
if embed_fn is None:
|
||||
return _split_recursive(text, chunk_size, overlap)
|
||||
sentences = _split_into_sentences(text)
|
||||
if len(sentences) < 3:
|
||||
return _split_recursive(text, chunk_size, overlap)
|
||||
try:
|
||||
vectors = list(embed_fn(sentences))
|
||||
except Exception: # noqa: BLE001 - embedder failure -> lexical fallback, never abort ingest
|
||||
return _split_recursive(text, chunk_size, overlap)
|
||||
if len(vectors) != len(sentences):
|
||||
return _split_recursive(text, chunk_size, overlap)
|
||||
|
||||
from forge.knowledge.embeddings import cosine
|
||||
|
||||
# Distance (1 - cosine) between each adjacent sentence pair; a large value = a topic shift.
|
||||
# Materialize each vector to a list ONCE (each is otherwise re-listed as both a left and a
|
||||
# right neighbor).
|
||||
vecs = [list(v) for v in vectors]
|
||||
dists = [1.0 - cosine(vecs[i], vecs[i + 1]) for i in range(len(vecs) - 1)]
|
||||
threshold = _percentile(sorted(dists), _SEMANTIC_BREAKPOINT_PERCENTILE)
|
||||
|
||||
segments: list[str] = []
|
||||
cur = [sentences[0]]
|
||||
for i in range(1, len(sentences)):
|
||||
if dists[i - 1] > threshold: # sharp enough drop -> boundary before this sentence
|
||||
segments.append(" ".join(cur))
|
||||
cur = []
|
||||
cur.append(sentences[i])
|
||||
if cur:
|
||||
segments.append(" ".join(cur))
|
||||
|
||||
chunks: list[str] = []
|
||||
for seg in segments:
|
||||
if len(seg) <= chunk_size:
|
||||
chunks.append(seg)
|
||||
else:
|
||||
chunks.extend(_split_recursive(seg, chunk_size, overlap))
|
||||
return [c for c in chunks if c.strip()]
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Chroma-backed EmbeddingStore (the user-mandated vector store).
|
||||
|
||||
Embedded persistent client (no server). One collection, scoped by tenant_id +
|
||||
project_id metadata so it's multi-tenant. We pass embeddings explicitly (our own
|
||||
embedder), so Chroma never needs to download its default model - fully offline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from forge.config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class Hit:
|
||||
id: str
|
||||
text: str
|
||||
score: float
|
||||
metadata: dict
|
||||
# In hybrid mode `score` is the normalized RRF fusion RANK (top≈1.0), NOT cosine, so a
|
||||
# cosine floor (min_score) can't be applied to it. `vector_score` carries the underlying
|
||||
# dense cosine similarity for the SAME chunk so callers can threshold the true scale (see
|
||||
# nodes/rag.py). None when the chunk surfaced from BM25 only (no dense score to compare).
|
||||
vector_score: float | None = None
|
||||
|
||||
|
||||
def _where(tenant_id: str, project_id: str, source_ids: list[str] | None) -> dict:
|
||||
clauses: list[dict] = [{"tenant_id": {"$eq": tenant_id}}, {"project_id": {"$eq": project_id}}]
|
||||
if source_ids:
|
||||
clauses.append({"source_id": {"$in": list(source_ids)}})
|
||||
return {"$and": clauses} if len(clauses) > 1 else clauses[0]
|
||||
|
||||
|
||||
def citation_for(metadata: dict | None) -> str:
|
||||
"""A short human-readable citation for a retrieved chunk, built from the source
|
||||
provenance now persisted in chunk metadata (see services.knowledge.ingest). Prefers a
|
||||
crawled page's URL/title, then the source name, then the source URI. Empty string when
|
||||
no provenance is available (legacy chunks ingested before provenance was recorded), so
|
||||
callers can omit the citation rather than print a blank one."""
|
||||
m = metadata or {}
|
||||
page_url = m.get("page_url")
|
||||
title = m.get("page_title") or m.get("source_name")
|
||||
if page_url:
|
||||
return f"{title} — {page_url}" if title and title != page_url else str(page_url)
|
||||
name = m.get("source_name")
|
||||
uri = m.get("source_uri")
|
||||
if name and uri:
|
||||
return f"{name} — {uri}"
|
||||
return str(name or uri or "")
|
||||
|
||||
|
||||
# Client + collection handles are cached process-wide: PersistentClient construction
|
||||
# and get_or_create_collection cost ~9s cold / ~10ms warm each (measured), and were
|
||||
# previously paid on every store call.
|
||||
_CLIENT_CACHE: dict[str, object] = {}
|
||||
_COL_CACHE: dict[tuple[str, str], object] = {}
|
||||
|
||||
# Per-collection write version, bumped on every upsert/delete. The BM25 cache stamps the
|
||||
# version it was built at and rebuilds when the collection changes, so a lexical index is
|
||||
# never served stale after an ingest/delete - and never rebuilt while the corpus is unchanged.
|
||||
_COL_VERSION: dict[tuple[str, str], int] = {}
|
||||
# Cached lexical index per (collection-key, where-clause): {key: (version, bm25, ids, by_id)}.
|
||||
# Building it (a full corpus scan + BM25 tokenization) previously ran on EVERY hybrid query
|
||||
# and on the event loop; now it is built once per corpus version and reused (see hybrid_query,
|
||||
# which itself runs off the loop via services.knowledge.search).
|
||||
_BM25_CACHE: dict[tuple, tuple] = {}
|
||||
# Max chunks pulled into the lexical corpus. Bounds the one-time build cost + cache memory;
|
||||
# lexical matches in chunks beyond this cap are invisible (rare - needs a huge single project).
|
||||
_CORPUS_CAP = 5000
|
||||
|
||||
|
||||
def _bump_version(key: tuple[str, str]) -> None:
|
||||
_COL_VERSION[key] = _COL_VERSION.get(key, 0) + 1
|
||||
|
||||
|
||||
# --- Backend-agnostic hybrid search --------------------------------------------------------
|
||||
# The lexical (BM25) index build + the RRF fusion are identical regardless of which vector
|
||||
# backend supplies the dense hits and the corpus scan, so they live here as free functions
|
||||
# operating on any store that exposes `_key`, `query_where`, and `_get_documents`. Both
|
||||
# ChromaStore and PgVectorStore delegate to them (DRY + a single caching path).
|
||||
|
||||
|
||||
def _build_lexical_index(store, where: dict, corpus_cap: int) -> tuple:
|
||||
"""Cached (bm25, ids, by_id) lexical index for `store`'s collection + where-clause. The
|
||||
corpus scan and BM25 tokenization are the expensive parts of a hybrid query; caching them
|
||||
per corpus-version turns every subsequent hybrid query into a cheap `get_scores` (rebuilt
|
||||
only after an ingest/delete bumps the version). by_id keeps chunk text + metadata so a
|
||||
BM25-only hit still carries its content."""
|
||||
from forge.knowledge.hybrid import build_bm25
|
||||
|
||||
version = _COL_VERSION.get(store._key, 0)
|
||||
cache_key = (store._key, json.dumps(where, sort_keys=True))
|
||||
cached = _BM25_CACHE.get(cache_key)
|
||||
if cached is not None and cached[0] == version:
|
||||
return cached[1], cached[2], cached[3]
|
||||
corpus = store._get_documents(where, limit=corpus_cap)
|
||||
built = build_bm25([(h.id, h.text) for h in corpus])
|
||||
bm25, ids = built if built else (None, [])
|
||||
by_id = {h.id: h for h in corpus}
|
||||
_BM25_CACHE[cache_key] = (version, bm25, ids, by_id)
|
||||
return bm25, ids, by_id
|
||||
|
||||
|
||||
def _hybrid_fuse(store, *, embedding, query: str, where: dict, top_k: int,
|
||||
candidate_pool: int | None, corpus_cap: int) -> list[Hit]:
|
||||
"""Fuse dense (vector) and lexical (BM25) ranking via RRF over `store`'s where-scoped
|
||||
corpus. Degrades to vector-only when BM25 is unavailable or the corpus has nothing to
|
||||
match. The returned `score` is the fused rank normalized to (0, 1] (NOT cosine); each Hit
|
||||
ALSO carries `vector_score`, the underlying dense cosine, so a caller's cosine floor
|
||||
thresholds the right scale."""
|
||||
from forge.knowledge.hybrid import bm25_scores, rrf_fuse
|
||||
|
||||
pool = candidate_pool or max(top_k * 5, 20)
|
||||
vec_hits = store.query_where(embedding=embedding, where=where, top_k=pool)
|
||||
vec_cos = {h.id: h.score for h in vec_hits} # dense cosine per chunk id
|
||||
bm25, ids, by_id = _build_lexical_index(store, where, corpus_cap)
|
||||
bm25_ids = bm25_scores((bm25, ids), query)
|
||||
if not bm25_ids:
|
||||
return [replace(h, vector_score=h.score) for h in vec_hits[:top_k]]
|
||||
|
||||
fused = rrf_fuse([h.id for h in vec_hits], bm25_ids)
|
||||
by_id = dict(by_id)
|
||||
by_id.update({h.id: h for h in vec_hits}) # prefer the vector hit's text/metadata
|
||||
max_score = max(fused.values()) or 1.0
|
||||
ranked = sorted(fused, key=lambda i: fused[i], reverse=True)[:top_k]
|
||||
return [
|
||||
Hit(id=by_id[i].id, text=by_id[i].text,
|
||||
score=round(fused[i] / max_score, 4), metadata=by_id[i].metadata,
|
||||
vector_score=vec_cos.get(i))
|
||||
for i in ranked if i in by_id
|
||||
]
|
||||
|
||||
|
||||
def _client_for(path: str):
|
||||
client = _CLIENT_CACHE.get(path)
|
||||
if client is None:
|
||||
import chromadb
|
||||
from chromadb.config import Settings as ChromaSettings
|
||||
|
||||
client = chromadb.PersistentClient(
|
||||
path=path, settings=ChromaSettings(anonymized_telemetry=False, allow_reset=True)
|
||||
)
|
||||
_CLIENT_CACHE[path] = client
|
||||
return client
|
||||
|
||||
|
||||
class ChromaStore:
|
||||
def __init__(self, path: str | None = None, collection: str = "forge_kb") -> None:
|
||||
path = path or settings.chroma_path
|
||||
# Collection is keyed by embedder dimension (e.g. forge_kb_256 / forge_kb_1536)
|
||||
# so different embedders never collide on a fixed-dim collection.
|
||||
col = _COL_CACHE.get((path, collection))
|
||||
if col is None:
|
||||
col = _client_for(path).get_or_create_collection(collection, metadata={"hnsw:space": "cosine"})
|
||||
_COL_CACHE[(path, collection)] = col
|
||||
self._client = _CLIENT_CACHE[path]
|
||||
self._col = col
|
||||
self._key = (path, collection)
|
||||
|
||||
def upsert(self, *, ids, embeddings, documents, metadatas) -> None:
|
||||
if not ids:
|
||||
return
|
||||
self._col.upsert(ids=ids, embeddings=embeddings, documents=documents, metadatas=metadatas)
|
||||
_bump_version(self._key) # invalidate any cached lexical index for this collection
|
||||
|
||||
def query(self, *, embedding, tenant_id, project_id, top_k=5, source_ids=None) -> list[Hit]:
|
||||
return self.query_where(
|
||||
embedding=embedding, where=_where(tenant_id, project_id, source_ids), top_k=top_k
|
||||
)
|
||||
|
||||
def query_where(self, *, embedding, where: dict, top_k: int = 5) -> list[Hit]:
|
||||
res = self._col.query(query_embeddings=[embedding], n_results=top_k, where=where)
|
||||
hits: list[Hit] = []
|
||||
ids = (res.get("ids") or [[]])[0]
|
||||
docs = (res.get("documents") or [[]])[0]
|
||||
metas = (res.get("metadatas") or [[]])[0]
|
||||
dists = (res.get("distances") or [[]])[0]
|
||||
for i, _id in enumerate(ids):
|
||||
dist = dists[i] if i < len(dists) else 0.0
|
||||
hits.append(Hit(id=_id, text=docs[i], score=1.0 - float(dist), metadata=metas[i] or {}))
|
||||
return hits
|
||||
|
||||
def _get_documents(self, where: dict, limit: int | None = None) -> list[Hit]:
|
||||
"""All stored chunks matching `where` (no vector query) - the corpus a lexical
|
||||
index is built over. score is 0.0 (unranked); `limit` caps the scan."""
|
||||
try:
|
||||
res = self._col.get(where=where, limit=limit) if limit else self._col.get(where=where)
|
||||
except Exception: # noqa: BLE001 - collection empty / not ready
|
||||
return []
|
||||
ids = res.get("ids") or []
|
||||
docs = res.get("documents") or []
|
||||
metas = res.get("metadatas") or []
|
||||
return [
|
||||
Hit(id=ids[i], text=(docs[i] if i < len(docs) else "") or "",
|
||||
score=0.0, metadata=(metas[i] if i < len(metas) else {}) or {})
|
||||
for i in range(len(ids))
|
||||
]
|
||||
|
||||
def hybrid_query(
|
||||
self, *, embedding, query: str, tenant_id, project_id, top_k=5, source_ids=None,
|
||||
candidate_pool: int | None = None, corpus_cap: int = _CORPUS_CAP,
|
||||
) -> list[Hit]:
|
||||
"""Fuse dense (vector) and lexical (BM25) ranking via RRF, scoped by the SAME
|
||||
tenant/project/source where-clause as vector search (see module `_hybrid_fuse`)."""
|
||||
return _hybrid_fuse(
|
||||
self, embedding=embedding, query=query, where=_where(tenant_id, project_id, source_ids),
|
||||
top_k=top_k, candidate_pool=candidate_pool, corpus_cap=corpus_cap,
|
||||
)
|
||||
|
||||
def dump(self, where: dict, limit: int | None = None, *, ids: list[str] | None = None) -> dict:
|
||||
"""Raw rows INCLUDING their embedding vectors - the input to the chunk map's
|
||||
dimensionality reduction. Returns {ids, documents, metadatas, embeddings} (parallel
|
||||
lists); embeddings come back as whatever numpy-ish rows Chroma stores. Empty on error.
|
||||
|
||||
Pass `ids` to fetch exactly those rows (used to pull in specific retrieved chunks that
|
||||
fell outside the sampled `limit` window); otherwise fetch up to `limit` rows matching
|
||||
`where`."""
|
||||
include = ["embeddings", "documents", "metadatas"]
|
||||
try:
|
||||
if ids:
|
||||
res = self._col.get(ids=ids, include=include)
|
||||
else:
|
||||
res = self._col.get(where=where, limit=limit, include=include)
|
||||
except Exception: # noqa: BLE001 - collection empty / not ready
|
||||
return {"ids": [], "documents": [], "metadatas": [], "embeddings": []}
|
||||
return {
|
||||
"ids": res.get("ids") or [],
|
||||
"documents": res.get("documents") or [],
|
||||
"metadatas": res.get("metadatas") or [],
|
||||
"embeddings": res.get("embeddings") if res.get("embeddings") is not None else [],
|
||||
}
|
||||
|
||||
def delete_ids(self, ids: list[str]) -> None:
|
||||
if ids:
|
||||
self._col.delete(ids=ids)
|
||||
_bump_version(self._key)
|
||||
|
||||
def delete_by_source(self, source_id: str, *, tenant_id: str | None = None, project_id: str | None = None) -> None:
|
||||
clauses: list[dict] = [{"source_id": {"$eq": source_id}}]
|
||||
if tenant_id:
|
||||
clauses.append({"tenant_id": {"$eq": tenant_id}})
|
||||
if project_id:
|
||||
clauses.append({"project_id": {"$eq": project_id}})
|
||||
where = {"$and": clauses} if len(clauses) > 1 else clauses[0]
|
||||
self._col.delete(where=where)
|
||||
_bump_version(self._key)
|
||||
|
||||
def delete_where(self, where: dict) -> None:
|
||||
"""Delete every chunk matching an arbitrary where-clause (used by project deletion to
|
||||
sweep a tenant/project's vectors). Public so callers never poke at `._col` directly."""
|
||||
self._col.delete(where=where)
|
||||
_bump_version(self._key)
|
||||
|
||||
def count(self, tenant_id: str, project_id: str) -> int:
|
||||
return self.count_where(_where(tenant_id, project_id, None))
|
||||
|
||||
def count_where(self, where: dict) -> int:
|
||||
# include=[] returns ids only (ids always come back) - Chroma otherwise also materializes
|
||||
# every matching row's documents+metadatas just to be counted.
|
||||
try:
|
||||
return len(self._col.get(where=where, include=[]).get("ids", []))
|
||||
except Exception: # noqa: BLE001
|
||||
return 0
|
||||
|
||||
def list_docs(self, where: dict) -> dict:
|
||||
"""ids + documents + metadatas (NO embeddings) for `where` - lighter than dump() for
|
||||
operations that only need chunk text (e.g. exact-duplicate detection)."""
|
||||
try:
|
||||
res = self._col.get(where=where, include=["documents", "metadatas"])
|
||||
except Exception: # noqa: BLE001 - collection empty / not ready
|
||||
return {"ids": [], "documents": [], "metadatas": []}
|
||||
return {
|
||||
"ids": res.get("ids") or [],
|
||||
"documents": res.get("documents") or [],
|
||||
"metadatas": res.get("metadatas") or [],
|
||||
}
|
||||
|
||||
def get_texts(self, ids: list[str], where: dict) -> dict:
|
||||
"""Documents + metadatas for specific `ids`, ADDITIONALLY constrained by `where` - so a
|
||||
caller-supplied id can't read a row outside its tenant/project. NO embeddings (lighter
|
||||
than dump()); backs the chunk-map detail panel's on-demand full-text fetch."""
|
||||
if not ids:
|
||||
return {"ids": [], "documents": [], "metadatas": []}
|
||||
try:
|
||||
res = self._col.get(ids=ids, where=where, include=["documents", "metadatas"])
|
||||
except Exception: # noqa: BLE001 - collection empty / not ready
|
||||
return {"ids": [], "documents": [], "metadatas": []}
|
||||
return {
|
||||
"ids": res.get("ids") or [],
|
||||
"documents": res.get("documents") or [],
|
||||
"metadatas": res.get("metadatas") or [],
|
||||
}
|
||||
|
||||
def ids_where(self, where: dict) -> list[str]:
|
||||
"""The ids currently stored matching `where` - lets a caller index only the rows
|
||||
that are actually MISSING (vs. a count comparison that can't detect stale ids)."""
|
||||
try:
|
||||
return list(self._col.get(where=where).get("ids", []))
|
||||
except Exception: # noqa: BLE001 - collection empty / not ready
|
||||
return []
|
||||
|
||||
|
||||
def make_store(collection: str = "forge_kb"):
|
||||
"""Return the configured embedding store for `collection`, keyed by embedder dim by the
|
||||
caller (e.g. forge_kb_384). `settings.vector_backend` selects the backend: "chroma"
|
||||
(embedded, single-writer) or "pgvector" (Postgres-backed, shared across workers). Both
|
||||
expose the same interface, so call sites are backend-agnostic."""
|
||||
if settings.vector_backend == "pgvector":
|
||||
from forge.knowledge.pgvector_store import PgVectorStore
|
||||
|
||||
return PgVectorStore(collection=collection)
|
||||
return ChromaStore(collection=collection)
|
||||
@@ -0,0 +1,279 @@
|
||||
"""FastAPI application factory + lifespan.
|
||||
|
||||
Builds our own server on the MIT LangChain/LangGraph framework - never depends on
|
||||
`langgraph-api` or LangSmith. Lifespan initializes the DB, the durable-execution
|
||||
checkpointer, and dev seed data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import threading
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
import forge
|
||||
from forge.config import settings
|
||||
from forge.db import SessionLocal, init_db
|
||||
from forge.db.seed import bootstrap, seed_demo_data
|
||||
from forge.routers import (
|
||||
agents,
|
||||
assistant,
|
||||
audit,
|
||||
auth,
|
||||
auth_providers,
|
||||
channels,
|
||||
components,
|
||||
connections,
|
||||
conversations,
|
||||
embed,
|
||||
embed_public,
|
||||
evals,
|
||||
handoff,
|
||||
health,
|
||||
hooks,
|
||||
knowledge,
|
||||
mcp_clients,
|
||||
mcp_oauth,
|
||||
mcp_server,
|
||||
mcp_tokens,
|
||||
models,
|
||||
nodes,
|
||||
oauth,
|
||||
pricing,
|
||||
project_run,
|
||||
projects,
|
||||
runs,
|
||||
secrets,
|
||||
stats,
|
||||
tool_sets,
|
||||
tools,
|
||||
traces,
|
||||
versions,
|
||||
workflows,
|
||||
)
|
||||
from forge.routers import (
|
||||
triggers as triggers_router,
|
||||
)
|
||||
from forge.util.http import aclose_shared_client
|
||||
|
||||
|
||||
async def _make_checkpointer(stack: AsyncExitStack):
|
||||
"""Durable-execution checkpointer. Selected by FORGE_CHECKPOINT_BACKEND:
|
||||
- "postgres": durable + shared across workers (REQUIRED for prod/HITL; audit P2).
|
||||
- "memory": ephemeral (tests / throwaway).
|
||||
- "sqlite" (default): local file; fine for single-worker dev, lost on restart."""
|
||||
backend = (settings.checkpoint_backend or "sqlite").lower()
|
||||
if backend == "memory" or settings.checkpoint_db == "memory":
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
return InMemorySaver()
|
||||
if backend == "postgres":
|
||||
try:
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
except ImportError as e: # pragma: no cover - optional extra
|
||||
raise RuntimeError(
|
||||
"FORGE_CHECKPOINT_BACKEND=postgres needs langgraph-checkpoint-postgres "
|
||||
"(pip install -e '.[postgres]')."
|
||||
) from e
|
||||
dsn = settings.checkpoint_postgres_url or settings.database_url
|
||||
# LangGraph wants a plain libpq DSN, not the SQLAlchemy +asyncpg/+psycopg form.
|
||||
for prefix in ("+asyncpg", "+psycopg", "+psycopg2"):
|
||||
dsn = dsn.replace(prefix, "")
|
||||
cp = await stack.enter_async_context(AsyncPostgresSaver.from_conn_string(dsn))
|
||||
try:
|
||||
await cp.setup()
|
||||
except Exception: # noqa: BLE001 - setup is idempotent
|
||||
pass
|
||||
return cp
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
|
||||
cp = await stack.enter_async_context(AsyncSqliteSaver.from_conn_string(settings.checkpoint_db))
|
||||
try:
|
||||
await cp.setup()
|
||||
except Exception: # noqa: BLE001 - setup is idempotent; ignore "already exists"
|
||||
pass
|
||||
return cp
|
||||
|
||||
|
||||
async def _reaper_loop(app: FastAPI) -> None:
|
||||
"""Periodically reap runs stuck in queued/running (never streamed, or driver died) so
|
||||
they can't linger forever (audit F3)."""
|
||||
from forge.services.runs import RunService
|
||||
|
||||
log = logging.getLogger("forge.reaper")
|
||||
run_service = RunService(checkpointer=app.state.checkpointer, store=app.state.store)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(300)
|
||||
await run_service.reap_stale_runs()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception: # noqa: BLE001 - keep the reaper alive across failures
|
||||
log.exception("reaper tick failed")
|
||||
|
||||
|
||||
async def _retention_loop() -> None:
|
||||
"""Purge traces/spans/runs past each project's retention horizon and audit logs past the
|
||||
workspace horizon, on a timer (finding e). Leader-only (like the reaper) so a multi-replica
|
||||
deployment purges once. No-op unless a retention window is configured."""
|
||||
from forge.services.retention import RetentionService
|
||||
|
||||
log = logging.getLogger("forge.retention")
|
||||
interval = max(60, int(settings.retention_interval_seconds or 3600))
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(interval)
|
||||
await RetentionService.purge_expired()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception: # noqa: BLE001 - keep the retention loop alive across failures
|
||||
log.exception("retention tick failed")
|
||||
|
||||
|
||||
async def _scheduler_loop(app: FastAPI) -> None:
|
||||
"""Fire due `schedule` triggers once a minute. Single-worker in-process scheduler;
|
||||
for multi-worker prod, move to arq/Redis (FORGE_REDIS_URL) so it runs once globally."""
|
||||
from forge.services.dispatch import run_due_app_events, run_due_schedules
|
||||
from forge.services.runs import RunService
|
||||
|
||||
log = logging.getLogger("forge.scheduler")
|
||||
run_service = RunService(checkpointer=app.state.checkpointer, store=app.state.store)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(60)
|
||||
await run_due_schedules(run_service)
|
||||
await run_due_app_events(run_service)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception: # noqa: BLE001 - keep the scheduler alive across failures
|
||||
log.exception("scheduler tick failed")
|
||||
|
||||
|
||||
def _preload_heavy_modules() -> None:
|
||||
"""Import the slow modules off the critical path. First import of langchain_openai
|
||||
/ chromadb costs ~22s / ~9s on this machine (AV scanning); doing it in a daemon
|
||||
thread at startup means the first real run doesn't pay it."""
|
||||
import importlib
|
||||
|
||||
for mod in ("langchain_openai", "chromadb", "langchain.chat_models"):
|
||||
try:
|
||||
importlib.import_module(mod)
|
||||
except Exception: # noqa: BLE001 - optional providers may be missing
|
||||
pass
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Install the IPv4-first DNS resolver before anything opens a connection, so outbound calls
|
||||
# (LLM providers, REST tools, DB, redis) never pay the multi-second AAAA-lookup stall.
|
||||
if settings.prefer_ipv4_egress:
|
||||
from forge.util.netfix import install_prefer_ipv4_dns
|
||||
|
||||
install_prefer_ipv4_dns()
|
||||
settings.ensure_dirs()
|
||||
problems = settings.validate_production()
|
||||
if problems:
|
||||
# Refuse to serve a misconfigured production install (default secrets, auth off,
|
||||
# SSRF guard off, SQLite, non-durable checkpointer, unsandboxed code). Set the
|
||||
# flagged env vars before deploying. Enforced for every non-dev environment (S6).
|
||||
raise RuntimeError("Unsafe production configuration:\n - " + "\n - ".join(problems))
|
||||
for warn in settings.startup_warnings():
|
||||
logging.getLogger("forge.config").warning("INSECURE CONFIG: %s", warn)
|
||||
await init_db()
|
||||
threading.Thread(target=_preload_heavy_modules, name="forge-preload", daemon=True).start()
|
||||
app.state.exit_stack = AsyncExitStack()
|
||||
app.state.checkpointer = await _make_checkpointer(app.state.exit_stack)
|
||||
app.state.store = None
|
||||
async with SessionLocal() as session:
|
||||
tenant_id = await bootstrap(session)
|
||||
if settings.seed_demo:
|
||||
await seed_demo_data(session, tenant_id)
|
||||
app.state.tenant_id = tenant_id
|
||||
from forge.routers.pricing import load_pricing_overrides
|
||||
|
||||
await load_pricing_overrides(session)
|
||||
if settings.otel_enabled:
|
||||
from forge.tracing import otel
|
||||
|
||||
otel.configure()
|
||||
bg_tasks: list[asyncio.Task] = []
|
||||
# The scheduler must run on EXACTLY ONE instance (else every replica double-fires).
|
||||
# `enable_scheduler` turns it on; `scheduler_leader` elects the single instance by env
|
||||
# so you can ship one image everywhere (audit P3).
|
||||
if settings.enable_scheduler and settings.scheduler_leader:
|
||||
bg_tasks.append(asyncio.create_task(_scheduler_loop(app), name="forge-scheduler"))
|
||||
# The reaper is safe to run everywhere (idempotent), but one instance is enough.
|
||||
if settings.scheduler_leader:
|
||||
bg_tasks.append(asyncio.create_task(_reaper_loop(app), name="forge-reaper"))
|
||||
# Data-retention purge (leader-only): ages out traces/spans/runs + audit logs (finding e).
|
||||
if settings.enable_retention and settings.scheduler_leader:
|
||||
bg_tasks.append(asyncio.create_task(_retention_loop(), name="forge-retention"))
|
||||
yield
|
||||
for t in bg_tasks:
|
||||
t.cancel()
|
||||
for t in bg_tasks:
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await t
|
||||
from forge.util.tasks import drain
|
||||
|
||||
await drain()
|
||||
with contextlib.suppress(Exception):
|
||||
from forge.tools.mcp import close_all
|
||||
|
||||
await close_all()
|
||||
with contextlib.suppress(Exception):
|
||||
from forge.queue import close_pool
|
||||
|
||||
await close_pool()
|
||||
await aclose_shared_client()
|
||||
await app.state.exit_stack.aclose()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="Forge API",
|
||||
version=forge.__version__,
|
||||
description="Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents.",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
# Host-header allow-list (defense-in-depth against Host-header attacks). Empty => any (dev).
|
||||
if settings.trusted_hosts:
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
|
||||
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.trusted_hosts)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
# Coarse per-IP request ceiling (api_rate_limit_per_minute) as a blunt DoS guard;
|
||||
# complements the per-surface limits. Health/SSE exempt (finding a).
|
||||
if settings.enable_global_rate_limit:
|
||||
from forge.util.ratelimit import GlobalRateLimitMiddleware
|
||||
|
||||
app.add_middleware(GlobalRateLimitMiddleware)
|
||||
# Audit all successful mutations (pure ASGI; safe with SSE streams).
|
||||
from forge.audit_middleware import AuditMiddleware
|
||||
|
||||
app.add_middleware(AuditMiddleware)
|
||||
for r in (
|
||||
health.router, auth.router, auth.team_router, auth.workspace_router, auth.apikeys_router,
|
||||
audit.router, oauth.router, hooks.router,
|
||||
models.router, nodes.router, projects.router, workflows.router, runs.router, project_run.router,
|
||||
tool_sets.router, tools.router, components.router, embed.router, embed_public.router, auth_providers.router, connections.router, secrets.router, agents.router,
|
||||
knowledge.router, knowledge.qa_router, traces.router, conversations.router, assistant.router, stats.router,
|
||||
triggers_router.router, channels.router, channels.public, handoff.router, evals.router,
|
||||
pricing.router, mcp_oauth.router, mcp_server.router, mcp_tokens.router, mcp_clients.router, versions.router,
|
||||
):
|
||||
app.include_router(r)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Canonical chat-model catalog - the SINGLE source of truth for the model picker.
|
||||
|
||||
The frontend dropdown is served from here (`GET /v1/models`), and the built-in chat-model
|
||||
rates in `forge.tracing.pricing` are derived from the same list. That means a model can only
|
||||
appear in the UI if the backend also knows how to run and price it - the two can't drift, so
|
||||
cost tracking never silently reports $0 for something a user actually selected.
|
||||
|
||||
Add a chat model = add one `ModelInfo` row here (with its price). Non-chat priced models
|
||||
(embeddings) and any priced-but-not-offered models live in `pricing.py`'s extras.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelInfo:
|
||||
id: str # provider-prefixed id passed to init_chat_model, e.g. "openai:gpt-4o-mini"
|
||||
name: str # short display name
|
||||
provider: str # display label: OpenAI | Anthropic | Google | Local
|
||||
ctx: str # context window (display only)
|
||||
tools: bool # supports tool/function calling
|
||||
vision: bool # accepts image input
|
||||
input_per_1m: float # USD / 1M input tokens
|
||||
output_per_1m: float # USD / 1M output tokens
|
||||
|
||||
@property
|
||||
def bare(self) -> str:
|
||||
"""Model id without the provider prefix - the key pricing matches on."""
|
||||
return self.id.split(":", 1)[1] if ":" in self.id else self.id
|
||||
|
||||
|
||||
# Ordered cheap -> expensive within each provider so the fast/cheap picks surface first.
|
||||
CHAT_MODELS: list[ModelInfo] = [
|
||||
# OpenAI
|
||||
ModelInfo("openai:gpt-4.1-nano", "gpt-4.1-nano", "OpenAI", "1M", True, True, 0.1, 0.4),
|
||||
ModelInfo("openai:gpt-4o-mini", "gpt-4o-mini", "OpenAI", "128k", True, True, 0.15, 0.6),
|
||||
ModelInfo("openai:gpt-4.1-mini", "gpt-4.1-mini", "OpenAI", "1M", True, True, 0.4, 1.6),
|
||||
ModelInfo("openai:gpt-4o", "gpt-4o", "OpenAI", "128k", True, True, 2.5, 10.0),
|
||||
# Anthropic
|
||||
ModelInfo("anthropic:claude-3-5-haiku-latest", "claude-3-5-haiku", "Anthropic", "200k", True, False, 0.8, 4.0),
|
||||
ModelInfo("anthropic:claude-haiku-4-5", "claude-haiku-4-5", "Anthropic", "200k", True, True, 1.0, 5.0),
|
||||
ModelInfo("anthropic:claude-3-5-sonnet-latest", "claude-3-5-sonnet", "Anthropic", "200k", True, True, 3.0, 15.0),
|
||||
ModelInfo("anthropic:claude-sonnet-4-6", "claude-sonnet-4-6", "Anthropic", "200k", True, True, 3.0, 15.0),
|
||||
# Google
|
||||
ModelInfo("google_genai:gemini-1.5-flash", "gemini-1.5-flash", "Google", "1M", True, True, 0.075, 0.3),
|
||||
ModelInfo("google_genai:gemini-2.5-flash", "gemini-2.5-flash", "Google", "1M", True, True, 0.3, 2.5),
|
||||
# Offline / test - runs with no provider credentials; never priced (see catalog_prices).
|
||||
ModelInfo("fake:echo", "fake (offline test)", "Local", "-", True, False, 0.0, 0.0),
|
||||
]
|
||||
|
||||
|
||||
def catalog_prices() -> dict[str, tuple[float, float]]:
|
||||
"""Bare-name -> (input, output) rates for the catalog's real (non-fake) chat models.
|
||||
Merged into `pricing.PRICING` so the picker and the cost engine share one rate table."""
|
||||
return {
|
||||
m.bare: (m.input_per_1m, m.output_per_1m)
|
||||
for m in CHAT_MODELS
|
||||
if not m.id.startswith("fake")
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmbeddingModel:
|
||||
id: str # ref stored in rag_defaults.embedding_model, e.g. "fastembed:BAAI/bge-small-en-v1.5"
|
||||
name: str
|
||||
provider: str # Local | OpenAI
|
||||
dim: int # vector dimension (a Chroma collection is fixed-dim)
|
||||
billed: bool # True => billed per token at ingest and on every query
|
||||
default: bool = False # the embedder used when a project leaves it unset
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RerankerModel:
|
||||
id: str # cross-encoder id, e.g. "Xenova/ms-marco-MiniLM-L-6-v2"
|
||||
name: str
|
||||
note: str # short size/quality hint for the picker
|
||||
default: bool = False
|
||||
|
||||
|
||||
# Embedding models offered in the picker. `default` must match embeddings._DEFAULT_FASTEMBED;
|
||||
# billed models must be priced in pricing.py (both guarded by test_model_catalog).
|
||||
EMBEDDING_MODELS: list[EmbeddingModel] = [
|
||||
EmbeddingModel("fastembed:BAAI/bge-small-en-v1.5", "bge-small", "Local", 384, False, default=True),
|
||||
EmbeddingModel("fastembed:BAAI/bge-base-en-v1.5", "bge-base", "Local", 768, False),
|
||||
EmbeddingModel("openai:text-embedding-3-small", "OpenAI 3-small", "OpenAI", 1536, True),
|
||||
EmbeddingModel("openai:text-embedding-3-large", "OpenAI 3-large", "OpenAI", 3072, True),
|
||||
]
|
||||
|
||||
# Cross-encoder rerankers (local, CPU, offline). `default` must match rerank.DEFAULT_RERANKER.
|
||||
RERANKER_MODELS: list[RerankerModel] = [
|
||||
RerankerModel("Xenova/ms-marco-MiniLM-L-6-v2", "MiniLM-L6", "small, CPU-fast", default=True),
|
||||
RerankerModel("BAAI/bge-reranker-base", "bge-reranker-base", "heavier, more accurate"),
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
"""ORM models."""
|
||||
|
||||
from forge.models.entities import (
|
||||
Agent,
|
||||
AuditLog,
|
||||
AuthProvider,
|
||||
Channel,
|
||||
Component,
|
||||
Dataset,
|
||||
EntityVersion,
|
||||
HandoffRequest,
|
||||
KbSource,
|
||||
McpClient,
|
||||
Memory,
|
||||
ModelPrice,
|
||||
OAuthClient,
|
||||
Project,
|
||||
QaPair,
|
||||
Run,
|
||||
Secret,
|
||||
Span,
|
||||
Tenant,
|
||||
Thread,
|
||||
Tool,
|
||||
ToolSet,
|
||||
ToolSetMember,
|
||||
Trace,
|
||||
Trigger,
|
||||
User,
|
||||
Workflow,
|
||||
)
|
||||
|
||||
# Eval history tables live in a separate module (append-isolated from entities.py); imported
|
||||
# here so they register on Base.metadata for create_all (finding F2).
|
||||
from forge.models.evals import EvalResult, EvalRun
|
||||
|
||||
__all__ = [
|
||||
"Tenant", "User", "Project", "Workflow", "Thread", "Run", "Trace", "Span",
|
||||
"Tool", "ToolSet", "ToolSetMember", "AuthProvider", "Secret", "McpClient", "Agent", "KbSource", "QaPair",
|
||||
"AuditLog", "Trigger", "Channel", "Component", "HandoffRequest", "Dataset", "ModelPrice", "Memory",
|
||||
"EntityVersion", "EvalRun", "EvalResult", "OAuthClient",
|
||||
]
|
||||
@@ -0,0 +1,523 @@
|
||||
"""ORM entities (Doc 2 §4, focused subset for the current phases).
|
||||
|
||||
All leaf tables carry `tenant_id` for multi-tenant scoping (RLS is added with
|
||||
Postgres in prod; SQLite uses query-level scoping). JSON columns hold the
|
||||
schema-validated config / canvas / executable documents.
|
||||
|
||||
Note: `metadata` is reserved by SQLAlchemy's declarative Base, so the JSON column
|
||||
is exposed as the `meta` attribute (DB column name "metadata").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from forge.db.base import Base, PkTimestamp
|
||||
|
||||
|
||||
class Tenant(PkTimestamp, Base):
|
||||
__tablename__ = "tenants"
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
plan: Mapped[str] = mapped_column(String(50), default="free")
|
||||
region: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
settings: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
|
||||
class User(PkTimestamp, Base):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), index=True)
|
||||
email: Mapped[str] = mapped_column(String(320), index=True)
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
role: Mapped[str] = mapped_column(String(30), default="owner") # owner|admin|editor|viewer|connector
|
||||
status: Mapped[str] = mapped_column(String(20), default="active")
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
|
||||
|
||||
class Project(PkTimestamp, Base):
|
||||
__tablename__ = "projects"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
slug: Mapped[str] = mapped_column(String(200), index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
config: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
status: Mapped[str] = mapped_column(String(20), default="active") # active|draft
|
||||
archived: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# Public, safe-to-embed key for the chat widget (Phase 3b/4), indexed for O(1) lookup by
|
||||
# the public /v1/embed/{key} routes. None until embedding is enabled; the rest of the embed
|
||||
# settings (enabled, allowed_origins, workflow_id) live in config["embed"].
|
||||
embed_key: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
|
||||
|
||||
class Workflow(PkTimestamp, Base):
|
||||
__tablename__ = "workflows"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), ForeignKey("tenants.id"), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
canvas: Mapped[dict] = mapped_column(JSON, default=dict) # React Flow round-trip (UI-owned)
|
||||
executable: Mapped[dict] = mapped_column(JSON, default=dict) # compiler input (backend-owned)
|
||||
status: Mapped[str] = mapped_column(String(20), default="draft")
|
||||
active_version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
|
||||
|
||||
class Tool(PkTimestamp, Base):
|
||||
__tablename__ = "tools"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
kind: Mapped[str] = mapped_column(String(20)) # rest_api|graphql|code|mcp|builtin
|
||||
config: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
auth_provider_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_tested: Mapped[str | None] = mapped_column(String(20), nullable=True) # pass|fail|untested
|
||||
|
||||
|
||||
class ToolSet(PkTimestamp, Base):
|
||||
"""A named, describable group of tools ("tool set"). Tool sets are the unit of
|
||||
organization in the Tools screen (they render as folders) AND the unit of exposure: an
|
||||
agent can be granted a whole set (agent config.toolsets) and the MCP server can publish
|
||||
a set as a GitHub-style toolset. Membership is many-to-many via ToolSetMember, so a tool
|
||||
may live in several sets (label-style, not a single home folder). `description` is what
|
||||
an MCP client / the model reads to decide when to enable the set; `is_default` marks a
|
||||
set that's published by default when a project exposes its tools over MCP."""
|
||||
|
||||
__tablename__ = "tool_sets"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "project_id", "slug", name="uq_tool_set_slug"),)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
slug: Mapped[str] = mapped_column(String(120), index=True) # url-safe id, unique per project
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
icon: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# Publish this set (and its enabled tools) over MCP. The MCP surface is EXACTLY the enabled
|
||||
# tools of exposed sets - there are no loose "directly exposed" tools (GitHub-toolset model).
|
||||
exposed: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class ToolSetMember(PkTimestamp, Base):
|
||||
"""Many-to-many membership linking a Tool to a ToolSet (a tool can belong to several
|
||||
sets). No DB-level cascade (matching the rest of the schema); ToolSetService and
|
||||
ToolService delete the membership rows explicitly when a set or tool is removed."""
|
||||
|
||||
__tablename__ = "tool_set_members"
|
||||
__table_args__ = (UniqueConstraint("tool_set_id", "tool_id", name="uq_tool_set_member"),)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
tool_set_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
tool_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
|
||||
|
||||
class Component(PkTimestamp, Base):
|
||||
"""A user-authored UI component (Feature 2 - generative UI): saved HTML + CSS,
|
||||
declarative button `actions`, and a JSON-Schema for the `props` the agent supplies.
|
||||
Attached to agents like tools (agent config["components"]); at runtime each becomes a
|
||||
widget-tool that, when called, emits a `component` stream frame for the client to
|
||||
render - so the markup never enters the model's token stream, only the props do."""
|
||||
|
||||
__tablename__ = "components"
|
||||
# The name is used verbatim as the LLM tool name → unique per project so two components
|
||||
# can't shadow each other's widget (audit M2). Enforced on fresh DBs; the router also
|
||||
# pre-checks for the existing-table case (create_all won't add a constraint after the fact).
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "project_id", "name", name="uq_component_tenant_project_name"),)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
title: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
props_schema: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
html: Mapped[str] = mapped_column(Text, default="")
|
||||
css: Mapped[str] = mapped_column(Text, default="")
|
||||
actions: Mapped[list] = mapped_column(JSON, default=list)
|
||||
sample_props: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
kind: Mapped[str] = mapped_column(String(20), default="html") # html (sandboxed) | declarative (future)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
|
||||
|
||||
class Agent(PkTimestamp, Base):
|
||||
__tablename__ = "agents"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
config: Mapped[dict] = mapped_column(JSON, default=dict) # validated vs forge/nodes/agent
|
||||
# Creator attribution (denormalized email snapshot for display without a join).
|
||||
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
created_by_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||
|
||||
|
||||
class AuthProvider(PkTimestamp, Base):
|
||||
__tablename__ = "auth_providers"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
kind: Mapped[str] = mapped_column(String(40))
|
||||
config: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
credentials_ref: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
|
||||
|
||||
class Secret(PkTimestamp, Base):
|
||||
__tablename__ = "secrets"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
name: Mapped[str] = mapped_column(String(120), index=True)
|
||||
kind: Mapped[str] = mapped_column(String(40), default="generic")
|
||||
encrypted_value: Mapped[bytes] = mapped_column(LargeBinary)
|
||||
version: Mapped[int] = mapped_column(Integer, default=1)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
|
||||
|
||||
class KbSource(PkTimestamp, Base):
|
||||
__tablename__ = "kb_sources"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
kind: Mapped[str] = mapped_column(String(20)) # text|url|file|s3|api
|
||||
name: Mapped[str] = mapped_column(String(300))
|
||||
folder: Mapped[str] = mapped_column(String(200), default="", server_default="") # "" = unfiled
|
||||
uri: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="queued") # queued|processing|ready|error
|
||||
chunks: Mapped[int] = mapped_column(Integer, default=0)
|
||||
embedding_model: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
meta: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
|
||||
|
||||
@property
|
||||
def chunking_strategy(self) -> str | None:
|
||||
"""Chunking strategy used at ingest (recursive|section|sentence); lives in meta."""
|
||||
return (self.meta or {}).get("chunk_strategy")
|
||||
|
||||
@property
|
||||
def chunk_size(self) -> int | None:
|
||||
"""Target chunk size (chars) used at ingest; lives in meta, None until first ingest."""
|
||||
v = (self.meta or {}).get("chunk_size")
|
||||
return int(v) if v is not None else None
|
||||
|
||||
@property
|
||||
def chunk_overlap(self) -> int | None:
|
||||
"""Chunk overlap (chars) used at ingest; lives in meta, None until first ingest."""
|
||||
v = (self.meta or {}).get("chunk_overlap")
|
||||
return int(v) if v is not None else None
|
||||
|
||||
|
||||
class QaPair(PkTimestamp, Base):
|
||||
__tablename__ = "qa_pairs"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
question: Mapped[str] = mapped_column(Text)
|
||||
answer: Mapped[str] = mapped_column(Text)
|
||||
kind: Mapped[str] = mapped_column(String(30), default="faq") # faq|error_workaround
|
||||
tags: Mapped[list] = mapped_column(JSON, default=list)
|
||||
q_embedding: Mapped[list] = mapped_column(JSON, default=list)
|
||||
upvotes: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
class McpClient(PkTimestamp, Base):
|
||||
__tablename__ = "mcp_clients"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
transport: Mapped[str] = mapped_column(String(20), default="http")
|
||||
url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
command: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
args: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
headers_ref: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
disabled_tools: Mapped[list] = mapped_column(JSON, default=list) # remote tool names toggled off in the External MCP tab
|
||||
|
||||
|
||||
class Thread(PkTimestamp, Base):
|
||||
__tablename__ = "threads"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
workflow_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
user_external_id: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
lg_thread_id: Mapped[str] = mapped_column(String(100)) # passed to LangGraph configurable.thread_id
|
||||
title: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="active")
|
||||
meta: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
|
||||
|
||||
|
||||
class Run(PkTimestamp, Base):
|
||||
__tablename__ = "runs"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
workflow_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
thread_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
# Where this run originated, for the Traces conversation view. Set at create_run by each
|
||||
# caller: playground|api|embed|channel_email|webhook|schedule (assistant runs
|
||||
# have no Run row). Copied onto the Trace at finalize.
|
||||
source: Mapped[str] = mapped_column(String(40), default="playground")
|
||||
status: Mapped[str] = mapped_column(String(20), default="queued") # queued|running|interrupted|done|error
|
||||
input: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
output: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
total_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||
total_cost_usd: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
|
||||
class Trace(PkTimestamp, Base):
|
||||
__tablename__ = "traces"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
workflow_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
run_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
thread_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(300))
|
||||
status: Mapped[str] = mapped_column(String(20), default="running")
|
||||
started_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
latency_ms: Mapped[int] = mapped_column(Integer, default=0)
|
||||
total_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||
total_cost_usd: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
# --- Conversation view (Traces): one Trace = one turn; group by thread_id for a session. ---
|
||||
# Origin of the turn (copied from Run.source; assistant turns = "assistant").
|
||||
# Indexed: the Traces filter facets run a DISTINCT over this column.
|
||||
source: Mapped[str] = mapped_column(String(40), default="playground", index=True)
|
||||
# Display + filter label: "System" for playground/test/assistant, else the end user's name,
|
||||
# else "Unknown user". Denormalized so the conversation list is a single Trace query.
|
||||
actor: Mapped[str] = mapped_column(String(300), default="System", index=True)
|
||||
# Stable end-user id (disambiguates same-named users); None for anonymous / system.
|
||||
end_user_id: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True)
|
||||
# This turn's user message and the AI's response, captured from live state at finalize.
|
||||
user_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ai_response: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
meta: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class Trigger(PkTimestamp, Base):
|
||||
"""An event-driven entry point synced from a workflow's trigger nodes. The
|
||||
dispatcher (webhook route / scheduler / inbound email / chat) fires runs from these."""
|
||||
|
||||
__tablename__ = "triggers"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
workflow_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
node_id: Mapped[str] = mapped_column(String(64))
|
||||
kind: Mapped[str] = mapped_column(String(20)) # webhook_in|schedule|email_in|app_event
|
||||
key: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # webhook URL key
|
||||
config: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_fired_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="active")
|
||||
# Runtime state (e.g. app_event dedupe cursor / seen ids); NOT synced from the node.
|
||||
meta: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
|
||||
|
||||
|
||||
class Channel(PkTimestamp, Base):
|
||||
"""An email deployment surface that feeds a workflow.
|
||||
`config` holds SMTP/IMAP settings and secret refs. `key` is the public,
|
||||
unguessable id used in inbound endpoint URLs."""
|
||||
|
||||
__tablename__ = "channels"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
workflow_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
type: Mapped[str] = mapped_column(String(20)) # email
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
key: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
config: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
|
||||
class HandoffRequest(PkTimestamp, Base):
|
||||
"""A conversation escalated to a human (live-agent handoff). The run is paused at a
|
||||
`handoff` interrupt; an agent replies via the inbox, which resumes the run and pushes
|
||||
the answer back over the originating channel."""
|
||||
|
||||
__tablename__ = "handoff_requests"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
workflow_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
run_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
thread_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
channel_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
customer: Mapped[str | None] = mapped_column(String(300), nullable=True) # email / display name
|
||||
customer_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="open") # open|answered|closed
|
||||
agent_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
reply_context: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
|
||||
class Memory(PkTimestamp, Base):
|
||||
"""A long-term memory an agent stored - facts that should persist across threads
|
||||
(vs. the per-thread checkpointer). Recalled by semantic search; scoped per project
|
||||
(+ optional `scope` for per-user/per-conversation memory)."""
|
||||
|
||||
__tablename__ = "memories"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
scope: Mapped[str] = mapped_column(String(120), default="default", index=True)
|
||||
text: Mapped[str] = mapped_column(Text)
|
||||
kind: Mapped[str] = mapped_column(String(30), default="note")
|
||||
|
||||
|
||||
class ModelPrice(PkTimestamp, Base):
|
||||
"""Admin-editable per-model pricing override (USD per 1M tokens). Overlays the
|
||||
built-in defaults in tracing/pricing.py so rates can be corrected without a deploy."""
|
||||
|
||||
__tablename__ = "model_prices"
|
||||
model: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
||||
input_per_1m: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
output_per_1m: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
|
||||
class Dataset(PkTimestamp, Base):
|
||||
"""An evaluation dataset: inputs + expected outputs run against a workflow to score
|
||||
quality and catch regressions before publish."""
|
||||
|
||||
__tablename__ = "datasets"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
workflow_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
score_mode: Mapped[str] = mapped_column(String(20), default="contains") # contains|exact|regex|judge
|
||||
items: Mapped[list] = mapped_column(JSON, default=list) # [{input, expected}]
|
||||
last_pass_rate: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
|
||||
class AuditLog(PkTimestamp, Base):
|
||||
"""Append-only audit trail: who did what, when (Doc 2 §12). Written for auth
|
||||
events, secret reads, and create/update/delete of every resource. Never updated
|
||||
or deleted in normal operation."""
|
||||
|
||||
__tablename__ = "audit_logs"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
actor_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(80), index=True) # e.g. auth.login, secret.read, workflow.delete
|
||||
resource_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
resource_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
project_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="ok") # ok|denied|error
|
||||
meta: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
|
||||
|
||||
|
||||
class EntityVersion(PkTimestamp, Base):
|
||||
"""Immutable point-in-time snapshot of a versionable entity's config, captured on each
|
||||
save so a user can view history and restore a prior version. Generic across entity types
|
||||
(workflow|agent|tool|component|auth_provider|kb_source|project) - the `snapshot` JSON holds
|
||||
the entity's restorable fields. Retention is pruned to the configured version_history_limit
|
||||
per (entity_type, entity_id). See forge.services.versions."""
|
||||
|
||||
__tablename__ = "entity_versions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("entity_type", "entity_id", "version_no", name="uq_entity_version"),
|
||||
)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
entity_type: Mapped[str] = mapped_column(String(40), index=True)
|
||||
entity_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
version_no: Mapped[int] = mapped_column(Integer, default=1)
|
||||
label: Mapped[str | None] = mapped_column(String(300), nullable=True) # entity name at snapshot time / note
|
||||
snapshot: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
author_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
author_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||
|
||||
|
||||
class Span(PkTimestamp, Base):
|
||||
__tablename__ = "spans"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
trace_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
parent_span_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(300))
|
||||
kind: Mapped[str] = mapped_column(String(20)) # llm|tool|chain|retriever|agent|node|subagent
|
||||
started_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
latency_ms: Mapped[int] = mapped_column(Integer, default=0)
|
||||
input: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
output: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
model: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
input_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||
output_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||
cost_usd: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
attributes: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
|
||||
|
||||
# --- Platform-hardening tables (finding h/j). All new: dev create_all builds them; prod needs
|
||||
# an Alembic migration + the tenant-isolation policies in infra/postgres_rls.sql. -------------
|
||||
|
||||
|
||||
class ApiKey(PkTimestamp, Base):
|
||||
"""A hashed, revocable API key for server-to-server callers (finding h). Scoped to a tenant
|
||||
with a fixed role; the plaintext is shown ONCE at creation and only its SHA-256 hash is
|
||||
stored. Presented as `Authorization: Bearer <key>` and resolved in get_current_user. This
|
||||
is per-tenant and per-role, unlike the single static FORGE_SERVICE_API_TOKEN."""
|
||||
|
||||
__tablename__ = "api_keys"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
# Non-secret lookup hint (the key's leading chars), safe to display in the console.
|
||||
prefix: Mapped[str] = mapped_column(String(16), index=True)
|
||||
key_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) # sha256 hex of the full key
|
||||
role: Mapped[str] = mapped_column(String(30), default="editor") # owner|admin|editor|viewer
|
||||
status: Mapped[str] = mapped_column(String(20), default="active") # active|revoked
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(nullable=True) # optional hard expiry
|
||||
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
# Personal access token (MCP): when `user_id` is set this row is a per-user PAT (plaintext
|
||||
# prefix forge_pat_, minted by ApiKeyService.create_personal) used to authenticate an
|
||||
# individual over a project's MCP server AS an end_user, optionally scoped to one `project_id`.
|
||||
# A PAT is deliberately NOT a general-API principal - get_current_user only honors forge_sk_.
|
||||
user_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
project_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
|
||||
|
||||
class ProjectMember(PkTimestamp, Base):
|
||||
"""Per-project role grant (finding h). Present => the member's EFFECTIVE role on that project
|
||||
is the higher of this and their tenant-wide role; absent => the tenant-wide role applies, so
|
||||
this is purely additive and backward-compatible."""
|
||||
|
||||
__tablename__ = "project_members"
|
||||
__table_args__ = (UniqueConstraint("project_id", "user_id", name="uq_project_member"),)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
user_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
role: Mapped[str] = mapped_column(String(30), default="viewer") # owner|admin|editor|viewer
|
||||
|
||||
|
||||
class OAuthClient(PkTimestamp, Base):
|
||||
"""A dynamically-registered OAuth 2.1 client (RFC 7591) for the MCP authorization server.
|
||||
Public clients (no secret), identified by `client_id` with an exact-match redirect_uri
|
||||
allow-list. Registered open / pre-auth (any MCP client may register); the USER identity is
|
||||
established later at the authorize step. Global (no tenant_id) - it is a client registry, not
|
||||
tenant data. Only used when settings.mcp_oauth_enabled is on."""
|
||||
|
||||
__tablename__ = "oauth_clients"
|
||||
client_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
client_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
redirect_uris: Mapped[list] = mapped_column(JSON, default=list)
|
||||
|
||||
|
||||
class UserSecurity(PkTimestamp, Base):
|
||||
"""Per-user auth state kept OFF the `users` row (finding j) so it can be added without an
|
||||
ALTER of an existing table: email-verification flag + optional TOTP MFA. NOTE: `totp_secret`
|
||||
is stored as a base32 string; encrypt it at rest (secret store / KMS) before enabling MFA on
|
||||
a shared production install."""
|
||||
|
||||
__tablename__ = "user_security"
|
||||
__table_args__ = (UniqueConstraint("user_id", name="uq_user_security_user"),)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
user_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
email_verified_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||
totp_secret: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Eval history entities (finding F2): persisted eval runs + per-item results.
|
||||
|
||||
`Dataset` (in entities.py) only ever kept `last_pass_rate` - a single scalar with no
|
||||
history and no per-example detail, so you couldn't diff two runs or see WHICH example
|
||||
regressed. These two append-only tables record every eval run (timestamped, with a
|
||||
rollup + a reference to the previous pass rate for the regression gate) and every
|
||||
item's outcome (produced answer, pass/fail, numeric score, and the per-assertion
|
||||
breakdown). Kept in a separate module so the append is isolated from the shared
|
||||
entities.py (concurrent-edit safety); imported by forge.models so create_all registers them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Float, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from forge.db.base import Base, PkTimestamp
|
||||
|
||||
|
||||
class EvalRun(PkTimestamp, Base):
|
||||
"""One execution of a dataset against its workflow. `created_at` (from PkTimestamp) is
|
||||
the timestamp; `prev_pass_rate`/`regressed` back the publish-time regression gate."""
|
||||
|
||||
__tablename__ = "eval_runs"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
project_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
dataset_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
workflow_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
score_mode: Mapped[str] = mapped_column(String(20), default="contains")
|
||||
status: Mapped[str] = mapped_column(String(20), default="done") # done|error
|
||||
total: Mapped[int] = mapped_column(Integer, default=0)
|
||||
passed: Mapped[int] = mapped_column(Integer, default=0)
|
||||
pass_rate: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
# The dataset's pass rate BEFORE this run (the baseline the gate compares against); None
|
||||
# on the first ever run. `regressed` is set when the gate is on and the rate dropped.
|
||||
prev_pass_rate: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
regressed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
total_tokens: Mapped[int] = mapped_column(Integer, default=0)
|
||||
total_cost_usd: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
meta: Mapped[dict] = mapped_column("metadata", JSON, default=dict) # truncation, gate config, counts
|
||||
|
||||
|
||||
class EvalResult(PkTimestamp, Base):
|
||||
"""One dataset item's outcome within an EvalRun - the produced answer plus its score,
|
||||
so history + per-example diffing exist. `checks` holds the per-assertion breakdown when
|
||||
the item used an assertion list."""
|
||||
|
||||
__tablename__ = "eval_results"
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
eval_run_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
item_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
input: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
expected: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
answer: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
passed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
# scored | run_failed | unavailable | error - so an inconclusive judge/embedding item is
|
||||
# distinguishable from a genuine fail (a misleading 0% is exactly what finding F4 fixes).
|
||||
status: Mapped[str] = mapped_column(String(20), default="scored")
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
checks: Mapped[list] = mapped_column(JSON, default=list)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Built-in node factories. Importing this package registers every node type.
|
||||
|
||||
Registered: start, end, router, agent, deep_agent, llm, classifier, transform,
|
||||
human_input, handoff, webhook_out, emit_event, tool_call, retrieval,
|
||||
subworkflow, parallel_fanout, join, loop, and the triggers (webhook_in, schedule,
|
||||
email_in, app_event).
|
||||
"""
|
||||
|
||||
from forge.nodes import ( # noqa: F401 (import => register)
|
||||
agent_node,
|
||||
data,
|
||||
flow,
|
||||
llm_node,
|
||||
rag,
|
||||
triggers,
|
||||
)
|
||||
|
||||
|
||||
def load_builtin_nodes() -> None:
|
||||
"""No-op: importing `forge.nodes` already registered the built-ins."""
|
||||
return None
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Agent node (`create_agent`) and Deep Agent node (`create_deep_agent`).
|
||||
|
||||
Doc 2 §9. Both produce a compiled LangGraph graph used as a node inside the
|
||||
workflow. The embedded agent does NOT carry its own checkpointer/store - the
|
||||
top-level workflow graph owns durability, and LangGraph propagates it to subgraphs
|
||||
at runtime (avoids nested-checkpointer conflicts and makes HITL interrupts bubble up).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.engine.middleware_compiler import build_middleware
|
||||
from forge.engine.models import resolve_model
|
||||
from forge.engine.registry import NodeSpec, Port, register
|
||||
|
||||
log = logging.getLogger("forge.agent")
|
||||
|
||||
|
||||
def _dedup_tools_by_name(tools: list) -> list:
|
||||
"""Bind each tool NAME to the model at most once. `resolve_tool_ids` already de-dups by id (a
|
||||
tool shared across several sets is ONE record → one id, sent once), but tool names are NOT
|
||||
unique per project and the final list mixes sources (tools + knowledge + MCP + components), so
|
||||
two entries can still collide by name - which providers reject (OpenAI errors on a duplicate
|
||||
function name). Keep the first occurrence; drop later name-collisions with a warning."""
|
||||
seen: set[str] = set()
|
||||
out: list = []
|
||||
for t in tools:
|
||||
name = getattr(t, "name", None)
|
||||
if name is not None and name in seen:
|
||||
log.warning("agent tool name %r appears more than once; keeping the first, dropping the rest", name)
|
||||
continue
|
||||
if name is not None:
|
||||
seen.add(name)
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
# Forge's default output style: every agent reply renders as GitHub-Flavored Markdown
|
||||
# (Feature 1 - structured responses). It lives in the system prompt, so it costs ~nothing
|
||||
# per turn (and is cached by the Anthropic prompt-caching middleware). Opt out with
|
||||
# config output_style="plain"; auto-skipped for structured-output agents (they emit JSON).
|
||||
OUTPUT_STYLE = (
|
||||
"Format every reply as GitHub-Flavored Markdown so it renders cleanly: short "
|
||||
"paragraphs; `##`/`###` headings for sections; `-` or numbered lists; GFM tables for "
|
||||
"comparisons or structured data; fenced code blocks with a language hint for code; and "
|
||||
"**bold** for key terms. Keep the structure minimal - only as much as the answer needs "
|
||||
"- and never output raw HTML."
|
||||
)
|
||||
|
||||
|
||||
# When UI components are attached, structured data should be shown via a component (table/
|
||||
# card/form), NOT a markdown table - so this variant drops the "GFM tables for structured
|
||||
# data" clause to avoid competing with the widgets (audit B1).
|
||||
OUTPUT_STYLE_WITH_COMPONENTS = (
|
||||
"Format every reply as GitHub-Flavored Markdown so it renders cleanly: short paragraphs; "
|
||||
"`##`/`###` headings; `-` or numbered lists; fenced code blocks with a language hint; and "
|
||||
"**bold** for key terms. For structured data (tables, cards, forms), prefer the available "
|
||||
"UI components over a markdown table. Keep structure minimal and never output raw HTML."
|
||||
)
|
||||
|
||||
|
||||
# Steer the agent to RENDER a fitting component instead of restating its data as prose, and to
|
||||
# POSITION it correctly: calling a component tool returns a placeholder marker that the agent
|
||||
# copies into its reply where the widget belongs - so the component is interleaved with the text
|
||||
# in its natural place (mid-answer, after a heading, at the end) rather than always pinned to the
|
||||
# top (which is what happens if placement is left to tool-call order). The last sentence is
|
||||
# load-bearing: it makes clear components only PRESENT data, so the agent keeps using its
|
||||
# retrieval/other tools normally - without it, the component guidance was competing with
|
||||
# knowledge/FAQ search and the agent skipped it (audit Priority B + the KB regression). Only
|
||||
# appended when config["components"] is non-empty.
|
||||
COMPONENT_STYLE = (
|
||||
"You have UI components available as tools (their names match the components). If a "
|
||||
"component fits the data you want to show (a table, card, form, …), you MUST call that "
|
||||
"component tool with the data as its props INSTEAD of writing the same data as prose or a "
|
||||
"markdown table. The tool returns a placeholder marker like [[forge:component:ID]]; copy that "
|
||||
"marker verbatim into your reply at the exact position where the component should appear. You "
|
||||
"control the order - write text before and after the marker so the component lands in its "
|
||||
"natural place in the answer (in the middle, after a heading, or at the end), exactly as it "
|
||||
"would read in a normal reply. Never restate the component's contents as text. This governs "
|
||||
"only how you PRESENT data - keep using your other tools (search the knowledge base, look up "
|
||||
"FAQs, call APIs) normally to GET the information you need."
|
||||
)
|
||||
|
||||
|
||||
def _build_prompt(config: dict) -> str | None:
|
||||
# Static system prompt + Forge's default Markdown output style (+ component guidance when
|
||||
# components are attached). Dynamic prompts compile to a middleware (added later).
|
||||
base = (config.get("system_prompt") or "").strip()
|
||||
structured = (config.get("response_format") or {}).get("mode") == "structured"
|
||||
if structured or config.get("output_style") == "plain":
|
||||
return base or None
|
||||
has_components = bool(config.get("components"))
|
||||
style = OUTPUT_STYLE_WITH_COMPONENTS if has_components else OUTPUT_STYLE
|
||||
parts = ([base] if base else []) + [style]
|
||||
if has_components:
|
||||
parts.append(COMPONENT_STYLE)
|
||||
return "\n\n".join(parts).strip()
|
||||
|
||||
|
||||
def _build_response_format(config: dict) -> Any:
|
||||
rf = config.get("response_format")
|
||||
if not rf or rf.get("mode") != "structured":
|
||||
return None
|
||||
# create_agent accepts a raw JSON-schema dict (auto provider/tool strategy).
|
||||
return rf.get("schema")
|
||||
|
||||
|
||||
# deepagents' built-in `task`-tool guidance is a ~536-token essay injected into EVERY supervisor
|
||||
# call. This concise replacement keeps the essential guidance at ~1/8th the tokens (a real per-turn
|
||||
# cost lever for supervisors, which re-read their prompt every turn). Override via SubAgentMiddleware.
|
||||
_TASK_TOOL_PROMPT = (
|
||||
"Use the `task` tool to delegate a self-contained subtask to one of the specialist subagents "
|
||||
"listed in its schema. Prefer it for multi-step work you can hand off wholesale, and dispatch "
|
||||
"independent subagents in parallel. You receive only each subagent's final result — not its "
|
||||
"intermediate steps — so give it a clear, complete instruction. Don't delegate trivial one-tool "
|
||||
"lookups; do those yourself."
|
||||
)
|
||||
|
||||
|
||||
def build_subagents(subagents_cfg: list[dict], ctx: CompileContext, default_model: Any = None) -> list[dict]:
|
||||
"""Convert subagent configs to the SubAgent dict shape SubAgentMiddleware expects.
|
||||
|
||||
Standalone SubAgentMiddleware (our lean deep_agent, see agent_factory) REQUIRES each subagent to
|
||||
carry `model` and `tools` - create_deep_agent used to fill those in - and deepagents re-resolves
|
||||
a *string* model via init_chat_model WITHOUT our injected provider key, so we always set a
|
||||
RESOLVED model object (defaulting to the parent deep_agent's model) and a concrete tools list.
|
||||
`system_prompt` is always set (deepagents wraps it). `workflow_ref` subagents are the still-
|
||||
unwired subworkflow phase.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for sa in subagents_cfg or []:
|
||||
if "workflow_ref" in sa:
|
||||
continue # TODO(phase: subworkflow): wrap compiled workflow as CompiledSubAgent
|
||||
name = sa["name"]
|
||||
spec: dict[str, Any] = {
|
||||
"name": name,
|
||||
"description": sa.get("description", ""),
|
||||
"system_prompt": sa.get("system_prompt") or sa.get("description") or f"You are the {name} agent.",
|
||||
"tools": _dedup_tools_by_name(ctx.tools_for(ctx.resolve_tool_ids(sa.get("tools"), sa.get("toolsets")))),
|
||||
"model": resolve_model(sa["model"], ctx) if sa.get("model") else (default_model or resolve_model(None, ctx)),
|
||||
}
|
||||
if sa.get("middleware"):
|
||||
spec["middleware"] = build_middleware(sa["middleware"], ctx)
|
||||
out.append(spec)
|
||||
return out
|
||||
|
||||
|
||||
def _maybe_add_prompt_caching(stack: list[dict], config: dict, ctx: CompileContext) -> list[dict]:
|
||||
"""Prepend Anthropic prompt-caching middleware for Anthropic-model agents (cost lever),
|
||||
unless already present or disabled. Best-effort: only when langchain-anthropic exists."""
|
||||
import importlib.util
|
||||
|
||||
from forge.config import settings
|
||||
|
||||
if not settings.default_anthropic_prompt_caching:
|
||||
return stack
|
||||
model_ref = config.get("model") or getattr(ctx, "default_model", "") or ""
|
||||
if not (isinstance(model_ref, str) and model_ref.startswith("anthropic")):
|
||||
return stack
|
||||
if any((m or {}).get("type") == "anthropic_prompt_caching" for m in stack):
|
||||
return stack
|
||||
if importlib.util.find_spec("langchain_anthropic") is None:
|
||||
return stack
|
||||
return [{"type": "anthropic_prompt_caching", "config": {}}, *stack]
|
||||
|
||||
|
||||
def _clamp(value, n: int = 300):
|
||||
"""Bound an end_user value's size before it enters the prompt (avoid bloat/abuse)."""
|
||||
if isinstance(value, str):
|
||||
return value[:n]
|
||||
if isinstance(value, list):
|
||||
return [_clamp(v, n) for v in value[:20]]
|
||||
if isinstance(value, dict):
|
||||
return {str(k)[:60]: _clamp(v, n) for k, v in list(value.items())[:20]}
|
||||
return value
|
||||
|
||||
|
||||
def _end_user_block(end_user: dict) -> str:
|
||||
"""A generic identity-awareness block. Only a whitelisted, size-clamped subset of the
|
||||
(untrusted-shaped) end_user is embedded, re-serialized so the JSON stays well-formed
|
||||
(audit L4). The withhold-restriction sentence is added ONLY when the user actually carries
|
||||
roles/entitlements - an unscoped prohibition with no entitlement list made the model
|
||||
over-refuse general KB/FAQ answers ("I don't have that information") (audit Priority A)."""
|
||||
import json as _json
|
||||
|
||||
safe = {
|
||||
k: _clamp(end_user[k])
|
||||
for k in ("id", "display_name", "email", "roles", "entitlements", "attributes")
|
||||
if end_user.get(k) not in (None, "", [], {})
|
||||
}
|
||||
if not safe:
|
||||
return ""
|
||||
eu = _json.dumps(safe, default=str, ensure_ascii=False)
|
||||
line = (
|
||||
"[END USER] You are assisting this authenticated end user, provided by the host "
|
||||
f"application - treat it as authoritative: {eu}."
|
||||
)
|
||||
if safe.get("roles") or safe.get("entitlements"):
|
||||
line += (
|
||||
" General product, FAQ, and knowledge-base information is available to everyone - "
|
||||
"always answer it. Only withhold data that is specific to OTHER users or accounts "
|
||||
"this user is not entitled to see or act on."
|
||||
)
|
||||
return line
|
||||
|
||||
|
||||
def _dynamic_field_middleware(config: dict, ctx: CompileContext, base_prompt: str | None) -> list:
|
||||
"""Compile the agent node's `dynamic_model` / `dynamic_prompt` blocks - both exposed in the
|
||||
UI but previously unwired (audit F9) - into middleware.
|
||||
|
||||
- dynamic_model reuses the proven `dynamic_model_by_state` builder (switch model by a
|
||||
state expression).
|
||||
- dynamic_prompt renders the FIRST matching rule's prompt (with `{{state.*}}` tokens) as
|
||||
the system prompt per model call, falling back to the node's static prompt when no rule
|
||||
matches - so enabling it with no matching rule is behavior-neutral."""
|
||||
extra: list = []
|
||||
|
||||
dm = config.get("dynamic_model") or {}
|
||||
if dm.get("enabled") and dm.get("rules"):
|
||||
extra += build_middleware(
|
||||
[{"type": "dynamic_model_by_state", "config": {"rules": dm["rules"], "default": dm.get("default")}}],
|
||||
ctx,
|
||||
)
|
||||
|
||||
dp = config.get("dynamic_prompt") or {}
|
||||
rules = dp.get("rules") or []
|
||||
if dp.get("enabled") and rules:
|
||||
from langchain.agents.middleware import dynamic_prompt as _dynamic_prompt
|
||||
|
||||
from forge.auth_providers.templates import render_template
|
||||
from forge.engine.expressions import ExpressionError, eval_truthy
|
||||
|
||||
fallback = base_prompt or ""
|
||||
|
||||
@_dynamic_prompt
|
||||
def _prompt(request): # type: ignore[no-untyped-def]
|
||||
state = dict(getattr(request, "state", {}) or {})
|
||||
for r in rules:
|
||||
text = r.get("prompt")
|
||||
if not text:
|
||||
continue
|
||||
when = r.get("when")
|
||||
try:
|
||||
if not when or eval_truthy(when, state):
|
||||
rendered = render_template(text, {"state": state}) if isinstance(text, str) else text
|
||||
return str(rendered) if rendered is not None else fallback
|
||||
except ExpressionError:
|
||||
continue
|
||||
return fallback
|
||||
|
||||
extra.append(_prompt)
|
||||
|
||||
return extra
|
||||
|
||||
|
||||
def _common_kwargs(config: dict, ctx: CompileContext) -> dict:
|
||||
tools = list(ctx.tools_for(ctx.resolve_tool_ids(config.get("tools"), config.get("toolsets"))))
|
||||
# Built-in knowledge access (RAG / Q&A) attached straight to the agent via its
|
||||
# `knowledge` config - no separate Tool row needed (see tools/builtin.py).
|
||||
if config.get("knowledge"):
|
||||
from forge.tools.builtin import build_knowledge_capability_tools
|
||||
tools += build_knowledge_capability_tools(config["knowledge"], ctx)
|
||||
# Agent-scoped MCP server access: attach each selected server's enabled tools
|
||||
# (pre-loaded by the runtime assembler into ctx.mcp_tools_by_client; native MCP tools).
|
||||
for cid in config.get("mcp_servers", []) or []:
|
||||
tools += (getattr(ctx, "mcp_tools_by_client", None) or {}).get(cid, [])
|
||||
# User-defined UI components exposed as widget-tools (Feature 2): the agent "renders"
|
||||
# one by calling it; the client draws the saved template from the props it passes.
|
||||
tools += list(ctx.components_for(config.get("components", [])))
|
||||
# Final guard: exactly one function name per model call, whatever the source mix.
|
||||
tools = _dedup_tools_by_name(tools)
|
||||
stack = (ctx.project_default_mw or []) + (config.get("middleware") or [])
|
||||
stack = _maybe_add_prompt_caching(stack, config, ctx)
|
||||
middleware = build_middleware(stack, ctx)
|
||||
model = resolve_model(config.get("model"), ctx, config.get("model_params"))
|
||||
|
||||
common: dict[str, Any] = {"model": model, "tools": tools, "middleware": middleware}
|
||||
prompt = _build_prompt(config)
|
||||
# Identity awareness: if the run acts for an end user, append a generic context block so
|
||||
# the agent knows who it's helping and to stay within their entitlements. Appended last,
|
||||
# so the (cacheable) instructions prefix is unchanged; only this per-user suffix varies.
|
||||
end_user = getattr(ctx, "end_user", None)
|
||||
if end_user:
|
||||
eu_block = _end_user_block(end_user)
|
||||
if eu_block:
|
||||
prompt = f"{prompt}\n\n{eu_block}" if prompt else eu_block
|
||||
if prompt:
|
||||
common["system_prompt"] = prompt
|
||||
# Wire the dynamic_model / dynamic_prompt config blocks (append after the static stack so
|
||||
# a matching rule overrides the base at call time). base_prompt = the fully-built static
|
||||
# prompt so a non-matching dynamic_prompt run reproduces the static behavior exactly.
|
||||
dynamic_mw = _dynamic_field_middleware(config, ctx, prompt)
|
||||
if dynamic_mw:
|
||||
common["middleware"] = list(middleware) + dynamic_mw
|
||||
rf = _build_response_format(config)
|
||||
if rf is not None:
|
||||
common["response_format"] = rf
|
||||
if config.get("name"):
|
||||
common["name"] = config["name"]
|
||||
return common
|
||||
|
||||
|
||||
def _resolve_config(config: dict, ctx: CompileContext) -> dict:
|
||||
"""If the node mirrors a saved agent (`agent_ref`), the live preset drives it - so
|
||||
edits in the Agents tab take effect without re-saving the workflow. Falls back to the
|
||||
node's own (snapshot) config when the preset is missing/unresolved."""
|
||||
ref = config.get("agent_ref")
|
||||
if ref:
|
||||
preset = (getattr(ctx, "agent_presets", None) or {}).get(ref)
|
||||
if preset:
|
||||
return dict(preset)
|
||||
return config
|
||||
|
||||
|
||||
def agent_factory(config: dict, ctx: CompileContext):
|
||||
config = _resolve_config(config, ctx)
|
||||
common = _common_kwargs(config, ctx)
|
||||
|
||||
from langchain.agents import create_agent
|
||||
|
||||
if config.get("flavor") == "deep_agent":
|
||||
# A deep_agent is `create_agent` + only the deepagents harness pieces the operator opts
|
||||
# into (planning / filesystem / subagents). We do NOT use `create_deep_agent`, which always
|
||||
# bundles the FULL harness (write_todos + filesystem + a general-purpose subagent + a large
|
||||
# base prompt) - that overhead made a simple lookup ~6x the tokens of a lean agent. Each
|
||||
# piece is added only when its config toggle is on, exactly like attaching a tool.
|
||||
try:
|
||||
from deepagents import SubAgentMiddleware
|
||||
from deepagents.backends import StateBackend
|
||||
except ImportError as e: # pragma: no cover - deepagents is a core dep
|
||||
raise ImportError(
|
||||
"deep_agent flavor needs `deepagents` (a core dependency - reinstall with "
|
||||
"`pip install -e .`)."
|
||||
) from e
|
||||
|
||||
# Backend for the filesystem / subagent middleware: a sandbox if configured, else the
|
||||
# default in-memory (thread-scoped) state backend - which carries NO shell `execute` tool.
|
||||
backend = ctx.sandbox_backend_for(config.get("sandbox", {}) or {}) or StateBackend()
|
||||
|
||||
middleware = list(common.get("middleware") or [])
|
||||
if config.get("planning"): # write_todos planner (off by default - pure token overhead)
|
||||
from langchain.agents.middleware import TodoListMiddleware
|
||||
middleware.append(TodoListMiddleware())
|
||||
fs = config.get("filesystem") or {}
|
||||
if fs.get("enabled") or (fs.get("backend") and fs.get("backend") != "none"):
|
||||
from deepagents.middleware import FilesystemMiddleware
|
||||
middleware.append(FilesystemMiddleware(backend=backend))
|
||||
# Deep-agent skills (agent-skills source paths): wire as SkillsMiddleware - the SAME
|
||||
# middleware create_deep_agent uses - so deep_agent NODES keep the skills capability under
|
||||
# the lean create_agent path (backend-backed, so skill files load from the configured store).
|
||||
if config.get("skills"):
|
||||
from deepagents.middleware.skills import SkillsMiddleware
|
||||
middleware.append(SkillsMiddleware(backend=backend, sources=list(config["skills"])))
|
||||
subagents = build_subagents(config.get("subagents", []), ctx, default_model=common["model"])
|
||||
if subagents:
|
||||
middleware.append(SubAgentMiddleware(backend=backend, subagents=subagents, system_prompt=_TASK_TOOL_PROMPT))
|
||||
|
||||
kwargs: dict[str, Any] = dict(common)
|
||||
kwargs["middleware"] = middleware
|
||||
return create_agent(**kwargs)
|
||||
|
||||
return create_agent(**common)
|
||||
|
||||
|
||||
def _summary(config: dict) -> list[str]:
|
||||
model = config.get("model", "-")
|
||||
n_tools = len(config.get("tools", []) or [])
|
||||
n_mw = len([m for m in (config.get("middleware") or []) if m.get("enabled", True)])
|
||||
flavor = config.get("flavor", "agent")
|
||||
line2 = f"{n_tools} tools · {n_mw} middleware"
|
||||
n_comp = len(config.get("components", []) or [])
|
||||
if n_comp:
|
||||
line2 += f" · {n_comp} widget{'s' if n_comp != 1 else ''}"
|
||||
k = config.get("knowledge") or {}
|
||||
kbits = [name for name, key in (("RAG", "rag"), ("Q&A", "qa")) if (k.get(key) or {}).get("enabled")]
|
||||
if kbits:
|
||||
line2 += " · KB " + "+".join(kbits)
|
||||
n_mcp = len(config.get("mcp_servers", []) or [])
|
||||
if n_mcp:
|
||||
line2 += f" · MCP {n_mcp}"
|
||||
if flavor == "deep_agent":
|
||||
line2 += f" · subagents {len(config.get('subagents', []) or [])}"
|
||||
return [str(model), line2]
|
||||
|
||||
|
||||
_ports = (
|
||||
[Port(id="in", io_type="messages", direction="in")],
|
||||
[Port(id="out", io_type="messages", direction="out")],
|
||||
)
|
||||
|
||||
register(
|
||||
NodeSpec(
|
||||
type="agent",
|
||||
schema_id="forge/nodes/agent",
|
||||
input_ports=_ports[0],
|
||||
output_ports=_ports[1],
|
||||
factory=agent_factory,
|
||||
allows_cycle=True,
|
||||
category="agents",
|
||||
label="Agent",
|
||||
description="ReAct tool loop",
|
||||
summarize=_summary,
|
||||
)
|
||||
)
|
||||
|
||||
register(
|
||||
NodeSpec(
|
||||
type="deep_agent",
|
||||
schema_id="forge/nodes/agent",
|
||||
input_ports=_ports[0],
|
||||
output_ports=_ports[1],
|
||||
factory=agent_factory,
|
||||
allows_cycle=True,
|
||||
category="agents",
|
||||
label="Deep Agent",
|
||||
description="Planning + subagents harness",
|
||||
summarize=_summary,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Data / integration nodes: transform, human_input, webhook_out, emit_event.
|
||||
|
||||
Convention: data nodes read from an optional `input_key` (else the whole state)
|
||||
and write to `output_key` (which MUST be a declared state field, else LangGraph
|
||||
rejects the update). `human_input` writes the decision into `messages`.
|
||||
|
||||
(`tool_call` and the RAG node land next: tool_call needs per-user context
|
||||
plumbing for auth'd tools; retrieval needs the Chroma store.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import jmespath
|
||||
|
||||
from forge.auth_providers.templates import render_value
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.engine.registry import NodeSpec, Port, register
|
||||
|
||||
log = logging.getLogger("forge.data")
|
||||
|
||||
|
||||
def _jq_transform(expr: str, input_key: str | None, output_key: str):
|
||||
"""Build a jq-powered transform node. jq is optional; if the `jq` package isn't installed
|
||||
we raise a clear ValueError WHEN THE NODE RUNS rather than silently falling back to
|
||||
JMESPath (which speaks a different language and would quietly produce wrong data) - audit
|
||||
F7. Compile succeeds so the rest of the workflow still previews."""
|
||||
try:
|
||||
import jq as _jq
|
||||
except ImportError:
|
||||
def _unavailable(state: dict) -> dict:
|
||||
raise ValueError(
|
||||
"transform engine 'jq' requires the `jq` package, which is not installed. "
|
||||
"Install it (pip install jq) or switch this transform's engine to 'jmespath'."
|
||||
)
|
||||
return _unavailable
|
||||
|
||||
try:
|
||||
program = _jq.compile(expr) # a malformed program surfaces here, at compile time
|
||||
except Exception as e: # noqa: BLE001 - re-raise as a clear config error
|
||||
raise ValueError(f"Invalid jq expression {expr!r}: {e}") from e
|
||||
|
||||
def _node(state: dict) -> dict:
|
||||
src = state.get(input_key) if input_key else dict(state)
|
||||
try:
|
||||
result: Any = program.input(src).first()
|
||||
except Exception as e: # noqa: BLE001 - a runtime jq failure -> None, but log it
|
||||
log.warning("transform jq %r failed: %s: %s", expr, type(e).__name__, e)
|
||||
result = None
|
||||
return {output_key: result}
|
||||
|
||||
return _node
|
||||
|
||||
|
||||
def transform_factory(cfg: dict, ctx: CompileContext):
|
||||
expr = cfg["expression"]
|
||||
engine = cfg.get("engine", "jmespath")
|
||||
input_key = cfg.get("input_key")
|
||||
output_key = cfg.get("output_key", "data")
|
||||
|
||||
if engine == "jq":
|
||||
return _jq_transform(expr, input_key, output_key)
|
||||
|
||||
def _node(state: dict) -> dict:
|
||||
src = state.get(input_key) if input_key else dict(state)
|
||||
try:
|
||||
result: Any = jmespath.search(expr, src)
|
||||
except jmespath.exceptions.JMESPathError as e:
|
||||
# Previously swallowed to None silently, which hid typo'd expressions; log it so a
|
||||
# broken transform is traceable in the run log (audit F7).
|
||||
log.warning("transform jmespath %r failed: %s: %s", expr, type(e).__name__, e)
|
||||
result = None
|
||||
return {output_key: result}
|
||||
|
||||
return _node
|
||||
|
||||
|
||||
def human_input_factory(cfg: dict, ctx: CompileContext):
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langgraph.types import interrupt
|
||||
|
||||
from forge.services.runs import HITL_APPROVAL_TIMEOUT_SECONDS
|
||||
|
||||
prompt = cfg["prompt"]
|
||||
decisions = cfg.get("allowed_decisions", ["approve", "reject"])
|
||||
schema = cfg.get("schema")
|
||||
# When set, also write the decision string to this state key so a downstream router
|
||||
# can branch on it (approve → continue, reject → end). The key must be declared in
|
||||
# workflow state (the canvas auto-declares node-written keys).
|
||||
output_key = cfg.get("output_key")
|
||||
# Deadline surfaced on the interrupt so operators/UI see how long the approval waits before
|
||||
# the reaper expires it (audit C). Per-node override, else the global HITL timeout (0 = none).
|
||||
timeout_seconds = cfg.get("timeout_seconds") or HITL_APPROVAL_TIMEOUT_SECONDS or None
|
||||
timeout_default = cfg.get("timeout_default")
|
||||
if timeout_default not in decisions:
|
||||
timeout_default = None
|
||||
|
||||
def _node(state: dict) -> dict:
|
||||
# Pauses the run; resumed via Command(resume=value). Node re-runs from the
|
||||
# top on resume, so the side effect (writing the decision) is placed after.
|
||||
decision = interrupt({
|
||||
"prompt": prompt, "allowed_decisions": decisions, "schema": schema,
|
||||
"timeout_seconds": timeout_seconds, "timeout_default": timeout_default,
|
||||
})
|
||||
out: dict[str, Any] = {"messages": [HumanMessage(content=f"[human decision] {decision}")]}
|
||||
if output_key:
|
||||
# Coerce a free-text resume value to one of allowed_decisions for the ROUTING key so a
|
||||
# Router keyed on approve/reject matches even on a direct API resume (audit C). The
|
||||
# transcript message above keeps the human's raw wording; only the routed value is
|
||||
# normalized. Structured (dict) input is left as-is.
|
||||
routed: Any = decision
|
||||
if isinstance(decision, str) and decisions:
|
||||
from forge.services.handoff import coerce_to_allowed_decision
|
||||
|
||||
routed = coerce_to_allowed_decision(decision, list(decisions))
|
||||
out[output_key] = str(routed)
|
||||
return out
|
||||
|
||||
return _node
|
||||
|
||||
|
||||
def handoff_factory(cfg: dict, ctx: CompileContext):
|
||||
"""Live-agent handoff: pause the run (interrupt) and hand the conversation to a
|
||||
human. The channel creates a HandoffRequest; when a human replies via the agent
|
||||
inbox, the run resumes with their text, which becomes the assistant's reply."""
|
||||
from langchain_core.messages import AIMessage
|
||||
from langgraph.types import interrupt
|
||||
|
||||
reason = cfg.get("reason", "Escalated to a human agent.")
|
||||
|
||||
def _node(state: dict) -> dict:
|
||||
reply = interrupt({"handoff": True, "reason": reason, "ack_message": cfg.get("ack_message")})
|
||||
return {"messages": [AIMessage(content=str(reply))]}
|
||||
|
||||
return _node
|
||||
|
||||
|
||||
def webhook_out_factory(cfg: dict, ctx: CompileContext):
|
||||
method = cfg["method"]
|
||||
url_t = cfg["url"]
|
||||
provider_id = cfg.get("auth_provider_id")
|
||||
output_key = cfg.get("output_key", "webhook_result")
|
||||
body_t = cfg.get("body")
|
||||
headers_t = cfg.get("headers", {})
|
||||
|
||||
async def _node(state: dict) -> dict:
|
||||
from forge.util.http import shared_async_client
|
||||
from forge.util.ssrf import validate_url
|
||||
|
||||
vars = {"state": dict(state)}
|
||||
url = render_value(url_t, vars)
|
||||
body = render_value(body_t, vars) if body_t else None
|
||||
headers = render_value(dict(headers_t), vars)
|
||||
params: dict[str, str] = {}
|
||||
cookies: dict[str, str] = {}
|
||||
if provider_id and ctx.auth_resolver:
|
||||
auth = await ctx.auth_resolver.resolve(
|
||||
tenant_id=ctx.tenant_id, project_id=ctx.project_id, provider_id=provider_id, context={}
|
||||
)
|
||||
headers.update(auth.headers)
|
||||
params.update(auth.params)
|
||||
cookies.update(auth.cookies)
|
||||
await validate_url(url, getattr(ctx, "egress_policy", None))
|
||||
c = shared_async_client()
|
||||
r = await c.request(method, url, headers=headers, params=params or None, json=body, cookies=cookies or None, timeout=30)
|
||||
try:
|
||||
out: Any = r.json()
|
||||
except Exception: # noqa: BLE001
|
||||
out = r.text
|
||||
return {output_key: out}
|
||||
|
||||
return _node
|
||||
|
||||
|
||||
def tool_call_factory(cfg: dict, ctx: CompileContext):
|
||||
tool_id = cfg["tool_id"]
|
||||
input_mapping = cfg.get("input_mapping", {}) or {}
|
||||
output_key = cfg.get("output_key", "tool_result")
|
||||
|
||||
async def _node(state: dict, config=None) -> dict:
|
||||
# Invoke the SAME materialized tool an agent would use, passing the run config so
|
||||
# the call is traced (the tracer is a callback on config) and so REST/GraphQL/
|
||||
# code/sql/mcp all go through one path with one error contract.
|
||||
tool = ctx.tool_registry.get(tool_id)
|
||||
if tool is None:
|
||||
return {output_key: {"error": f"tool {tool_id} not available"}}
|
||||
args: dict[str, Any] = {}
|
||||
for k, expr in input_mapping.items():
|
||||
try:
|
||||
args[k] = jmespath.search(expr, dict(state)) if isinstance(expr, str) else expr
|
||||
except jmespath.exceptions.JMESPathError:
|
||||
args[k] = expr
|
||||
try:
|
||||
out = await tool.ainvoke(args, config)
|
||||
except Exception as e: # noqa: BLE001 - surface tool failure as a structured result
|
||||
out = {"error": str(e)}
|
||||
return {output_key: out}
|
||||
|
||||
return _node
|
||||
|
||||
|
||||
def emit_event_factory(cfg: dict, ctx: CompileContext):
|
||||
channel = cfg["channel"]
|
||||
payload_t = cfg.get("payload", {})
|
||||
|
||||
def _node(state: dict) -> dict:
|
||||
try:
|
||||
from langgraph.config import get_stream_writer
|
||||
|
||||
get_stream_writer()({"channel": channel, "payload": render_value(payload_t, {"state": dict(state)})})
|
||||
except Exception: # noqa: BLE001 - no active stream writer (e.g. ainvoke)
|
||||
pass
|
||||
return {}
|
||||
|
||||
return _node
|
||||
|
||||
|
||||
_io_any = ([Port(id="in", io_type="any", direction="in")], [Port(id="out", io_type="any", direction="out")])
|
||||
|
||||
register(NodeSpec(
|
||||
type="transform", schema_id="forge/nodes/transform",
|
||||
input_ports=[Port(id="in", io_type="json", direction="in")],
|
||||
output_ports=[Port(id="out", io_type="json", direction="out")],
|
||||
factory=transform_factory, category="model_tools", label="Transform", description="JMESPath data map",
|
||||
summarize=lambda c: [f"{c.get('engine', 'jmespath')} · → {c.get('output_key', 'data')}"],
|
||||
))
|
||||
register(NodeSpec(
|
||||
type="human_input", schema_id="forge/nodes/human_input",
|
||||
input_ports=_io_any[0], output_ports=_io_any[1],
|
||||
factory=human_input_factory, category="human", label="Human Input", description="HITL pause via interrupt",
|
||||
summarize=lambda c: [c.get("prompt", "")[:40], " · ".join(c.get("allowed_decisions", ["approve", "reject"]))],
|
||||
))
|
||||
register(NodeSpec(
|
||||
type="tool_call", schema_id="forge/nodes/tool_call",
|
||||
input_ports=[Port(id="in", io_type="json", direction="in")],
|
||||
output_ports=[Port(id="out", io_type="json", direction="out")],
|
||||
factory=tool_call_factory, category="model_tools", label="Tool Call", description="Run a specific tool",
|
||||
summarize=lambda c: [str(c.get("tool_id", "-")), f"→ {c.get('output_key', 'tool_result')}"],
|
||||
))
|
||||
register(NodeSpec(
|
||||
type="webhook_out", schema_id="forge/nodes/webhook_out",
|
||||
input_ports=[Port(id="in", io_type="json", direction="in")],
|
||||
output_ports=[Port(id="out", io_type="json", direction="out")],
|
||||
factory=webhook_out_factory, category="integrations", label="Webhook", description="Call external URL",
|
||||
summarize=lambda c: [f"{c.get('method', 'POST')} {str(c.get('url', ''))[:32]}"],
|
||||
))
|
||||
register(NodeSpec(
|
||||
type="handoff", schema_id="forge/nodes/handoff",
|
||||
input_ports=_io_any[0], output_ports=_io_any[1],
|
||||
factory=handoff_factory, category="human", label="Human Handoff",
|
||||
description="Escalate the conversation to a human agent (pauses until they reply).",
|
||||
summarize=lambda c: [c.get("reason", "human handoff")[:40]],
|
||||
))
|
||||
register(NodeSpec(
|
||||
type="emit_event", schema_id="forge/nodes/emit_event",
|
||||
input_ports=_io_any[0], output_ports=_io_any[1],
|
||||
factory=emit_event_factory, category="integrations", label="Emit Event", description="Push custom SSE frame",
|
||||
summarize=lambda c: [f"channel · {c.get('channel', '')}"],
|
||||
))
|
||||
@@ -0,0 +1,441 @@
|
||||
"""Flow-control nodes: start, end, router.
|
||||
|
||||
- `start` / `end` are passthrough markers; the compiler wires START -> entry_node
|
||||
and every `end` node -> END.
|
||||
- `router` evaluates a sandboxed expression over state and routes to a case target.
|
||||
Its outgoing routing comes from `config.cases`/`config.default` (the compiler adds
|
||||
conditional edges), so labeled canvas edges out of a router are ignored at compile.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from langgraph.graph import END
|
||||
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.engine.expressions import ExpressionError, eval_expression, eval_truthy
|
||||
from forge.engine.registry import NodeSpec, Port, register
|
||||
|
||||
log = logging.getLogger("forge.flow")
|
||||
|
||||
# Keys a parallel_fanout stamps onto each child's Send payload (its INPUT state) so a child /
|
||||
# a downstream join can reassemble results in a STABLE order despite the nondeterministic
|
||||
# superstep completion order (audit F2). They ride only in the Send payload (verified: extra
|
||||
# Send-payload keys reach the child but never enter global workflow state), so they need not be
|
||||
# declared in `state` and never leak to the run output.
|
||||
FANOUT_INDEX_KEY = "_fanout_index"
|
||||
FANOUT_TOTAL_KEY = "_fanout_total"
|
||||
|
||||
|
||||
def _passthrough(state: dict) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def start_factory(config: dict, ctx: CompileContext):
|
||||
return _passthrough
|
||||
|
||||
|
||||
def end_factory(config: dict, ctx: CompileContext):
|
||||
return _passthrough
|
||||
|
||||
|
||||
def make_router_path(config: dict):
|
||||
"""Build a LangGraph path function: state -> target node id (or END).
|
||||
|
||||
With `multi: true`, a list-valued expression (e.g. a multi-label classifier's
|
||||
output) routes to EVERY matching case target - LangGraph runs them in parallel
|
||||
within one superstep, which is how one question with several intents reaches
|
||||
several specialists at once.
|
||||
"""
|
||||
expr = config["expression"]
|
||||
cases = config.get("cases", {}) or {}
|
||||
default = config.get("default")
|
||||
multi = bool(config.get("multi", False))
|
||||
|
||||
def _one(val: Any) -> str | None:
|
||||
# match by string key first (JSON object keys are strings), then raw value
|
||||
target = cases.get(str(val))
|
||||
if target is None and not isinstance(val, str) and val in cases:
|
||||
target = cases[val]
|
||||
return target
|
||||
|
||||
def _path(state: dict) -> Any:
|
||||
try:
|
||||
val = eval_expression(expr, dict(state or {}))
|
||||
except ExpressionError as e:
|
||||
# A failing router expression silently ending the run is a debugging nightmare;
|
||||
# log it (and fall through to the default/END) so it's traceable (audit F10).
|
||||
log.warning("router expression %r failed: %s", expr, e)
|
||||
val = None
|
||||
if multi:
|
||||
vals = list(val) if isinstance(val, (list, tuple, set)) else ([val] if val is not None else [])
|
||||
targets: list[str] = []
|
||||
for v in vals:
|
||||
t = _one(v)
|
||||
if t and t not in targets:
|
||||
targets.append(t)
|
||||
if targets:
|
||||
return targets
|
||||
if not default:
|
||||
log.warning("router %r matched no case and has no default; ending the run", expr)
|
||||
return default if default else END
|
||||
target = _one(val)
|
||||
if target is None:
|
||||
if not default:
|
||||
log.warning("router %r value %r matched no case and has no default; ending the run", expr, val)
|
||||
target = default
|
||||
return target if target else END
|
||||
|
||||
return _path
|
||||
|
||||
|
||||
def router_factory(config: dict, ctx: CompileContext):
|
||||
# The node itself is a passthrough; routing is added as conditional edges.
|
||||
return _passthrough
|
||||
|
||||
|
||||
def subworkflow_factory(config: dict, ctx: CompileContext):
|
||||
"""Compile a referenced workflow as a nested graph node (reusable component).
|
||||
|
||||
The sub-graph shares the parent's tool/agent/auth context but carries NO checkpointer
|
||||
(the top-level workflow owns durability - same rule as embedded agents). Recursion is
|
||||
broken by tracking in-progress workflow ids on the context.
|
||||
|
||||
Key mapping (audit F6): by default parent and child share state keys by name (the child
|
||||
reads/writes the same `messages` etc.). When `input_mapping` / `output_mapping` are set the
|
||||
node instead runs the child in ISOLATION and copies only the mapped keys across:
|
||||
- input_mapping: {parent_state_key: child_state_key} (parent -> child, before the run)
|
||||
- output_mapping: {child_state_key: parent_state_key} (child -> parent, after the run)
|
||||
`version`, when given, is honored best-effort: only the project's CURRENT executable per
|
||||
workflow id is available here, so a mismatch is logged rather than silently ignored."""
|
||||
import dataclasses
|
||||
|
||||
from forge.engine.compiler import compile_workflow
|
||||
|
||||
ref = config["workflow_id"]
|
||||
sub_def = (getattr(ctx, "workflows", {}) or {}).get(ref)
|
||||
if not sub_def:
|
||||
def _missing(state: dict) -> dict:
|
||||
return {}
|
||||
return _missing
|
||||
|
||||
want_version = config.get("version")
|
||||
if want_version is not None and sub_def.get("version") != want_version:
|
||||
# The runtime only carries the latest executable per workflow id/name (see
|
||||
# runtime.make_runtime_ctx), so we can't pin an older version here - surface it
|
||||
# instead of pretending the request was honored. Full pinning needs a
|
||||
# version-keyed workflow store (noted for a follow-up).
|
||||
log.warning(
|
||||
"subworkflow %r requested version %s but only version %s is available; using it",
|
||||
ref, want_version, sub_def.get("version"),
|
||||
)
|
||||
|
||||
compiling = getattr(ctx, "compiling", set())
|
||||
if ref in compiling: # cycle: refuse to recurse
|
||||
def _cycle(state: dict) -> dict:
|
||||
return {}
|
||||
return _cycle
|
||||
|
||||
compiling.add(ref)
|
||||
try:
|
||||
sub_ctx = dataclasses.replace(ctx, checkpointer=None, store=None)
|
||||
sub_graph = compile_workflow(sub_def, sub_ctx)
|
||||
finally:
|
||||
compiling.discard(ref)
|
||||
|
||||
input_mapping = config.get("input_mapping") or {}
|
||||
output_mapping = config.get("output_mapping") or {}
|
||||
if not input_mapping and not output_mapping:
|
||||
# Shared-state fast path (unchanged behavior): LangGraph runs the compiled child as a
|
||||
# subgraph, sharing state keys by name and bubbling interrupts up for HITL.
|
||||
return sub_graph
|
||||
|
||||
async def _mapped(state: dict, config=None) -> dict:
|
||||
child_in: dict[str, Any] = {}
|
||||
for parent_key, child_key in input_mapping.items():
|
||||
if parent_key in state:
|
||||
child_in[child_key] = state[parent_key]
|
||||
child_out = await sub_graph.ainvoke(child_in, config)
|
||||
out: dict[str, Any] = {}
|
||||
for child_key, parent_key in output_mapping.items():
|
||||
if child_key in child_out:
|
||||
out[parent_key] = child_out[child_key]
|
||||
return out
|
||||
|
||||
return _mapped
|
||||
|
||||
|
||||
def loop_factory(config: dict, ctx: CompileContext):
|
||||
"""Iterate: increment `_loop_count` and write `_loop` = 'continue'/'done' so a router
|
||||
can loop the body back here. Stops at `max_iter` or when `condition` is falsy. The
|
||||
body edge must return to this node (it `allows_cycle`); declare `_loop_count`/`_loop`
|
||||
in state (the canvas auto-declares node-written keys)."""
|
||||
max_iter = int(config.get("max_iter", 10))
|
||||
condition = config.get("condition")
|
||||
|
||||
def _node(state: dict) -> dict:
|
||||
# `_loop_count` is the running firing count; stop once it reaches max_iter. (Counting
|
||||
# contract is intentionally stable - see test_loop_node_counts_and_stops.)
|
||||
i = int(state.get("_loop_count", 0)) + 1
|
||||
cont = i < max_iter
|
||||
if cont and condition:
|
||||
try:
|
||||
cont = eval_truthy(condition, dict(state))
|
||||
except ExpressionError as e:
|
||||
# A failing loop condition silently ending the loop is hard to debug; log it.
|
||||
log.warning("loop condition %r failed: %s", condition, e)
|
||||
cont = False
|
||||
return {"_loop_count": i, "_loop": "continue" if cont else "done"}
|
||||
|
||||
return _node
|
||||
|
||||
|
||||
def make_fanout_path(config: dict):
|
||||
"""LangGraph Send-based map: run `child_node` once per item in `state[over]`, with the
|
||||
item placed at `item_key`. Children aggregate via an `add`-reducer state key.
|
||||
|
||||
Each Send payload is also stamped with the item's 0-based input index (`index_key`,
|
||||
default `_fanout_index`) and the batch size (`_fanout_total`) so a child - or a
|
||||
downstream join - can restore a STABLE order over the nondeterministic superstep
|
||||
completion order (audit F2). These extra keys live only in the child's input state and
|
||||
never enter the shared workflow state, so they need no `state` declaration."""
|
||||
from langgraph.types import Send
|
||||
|
||||
over = config["over"]
|
||||
child = config["child_node"]
|
||||
item_key = config["item_key"]
|
||||
index_key = config.get("index_key") or FANOUT_INDEX_KEY
|
||||
|
||||
def _path(state: dict) -> Any:
|
||||
items = state.get(over) or []
|
||||
if not items:
|
||||
# An empty fan-out produces no Sends, so the child (and anything gated on its
|
||||
# aggregated output) never runs. Log it so an empty `over` isn't a silent
|
||||
# dead-end the operator can't see (audit F10).
|
||||
log.warning("parallel_fanout over %r produced no items; no children dispatched", over)
|
||||
total = len(items)
|
||||
return [
|
||||
Send(child, {item_key: item, index_key: i, FANOUT_TOTAL_KEY: total})
|
||||
for i, item in enumerate(items)
|
||||
]
|
||||
|
||||
return _path
|
||||
|
||||
|
||||
def resilient_fanout_child(fn, *, timeout: float | None = None, isolate: bool = False):
|
||||
"""Wrap a parallel_fanout child so one item's failure/timeout doesn't abort the whole
|
||||
superstep (partial-failure isolation) and a slow item can be bounded (per-item timeout).
|
||||
|
||||
Only applied when the workflow opts in (error_policy "continue" or the fanout's
|
||||
on_item_error="skip"/item_timeout_seconds) - the compiler decides. LangGraph control-flow
|
||||
signals (interrupts / Command bubbling, `GraphBubbleUp`) and cancellation are ALWAYS
|
||||
re-raised so HITL keeps working; only genuine errors are isolated. A skipped item
|
||||
contributes no state update ({}). Sync children run inline and can't be preempted, so the
|
||||
per-item timeout applies to async children / compiled subgraphs only."""
|
||||
import asyncio
|
||||
import inspect
|
||||
|
||||
from langgraph.errors import GraphBubbleUp
|
||||
|
||||
is_runnable = hasattr(fn, "ainvoke")
|
||||
is_coro = inspect.iscoroutinefunction(fn)
|
||||
accepts_config = False
|
||||
if not is_runnable:
|
||||
try:
|
||||
accepts_config = len(inspect.signature(fn).parameters) >= 2
|
||||
except (TypeError, ValueError):
|
||||
accepts_config = False
|
||||
|
||||
async def _invoke(state: dict, config):
|
||||
if is_runnable:
|
||||
return await fn.ainvoke(state, config)
|
||||
if is_coro:
|
||||
return await (fn(state, config) if accepts_config else fn(state))
|
||||
# Plain sync node: call inline (JMESPath transforms etc. are trivial). It runs on the
|
||||
# event loop, so it can't be timed out - documented above.
|
||||
return fn(state, config) if accepts_config else fn(state)
|
||||
|
||||
async def _wrapped(state: dict, config=None) -> dict:
|
||||
idx = state.get(FANOUT_INDEX_KEY)
|
||||
try:
|
||||
if timeout and (is_runnable or is_coro):
|
||||
return await asyncio.wait_for(_invoke(state, config), timeout)
|
||||
return await _invoke(state, config)
|
||||
except GraphBubbleUp:
|
||||
raise # interrupts / Command bubbling must reach the parent for HITL to work
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001 - isolate a single item's failure when opted in
|
||||
if not isolate:
|
||||
raise
|
||||
log.warning(
|
||||
"parallel_fanout child failed for item #%s (%s: %s); skipping it",
|
||||
idx, type(e).__name__, e,
|
||||
)
|
||||
return {}
|
||||
|
||||
return _wrapped
|
||||
|
||||
|
||||
def _apply_join_reducer(reducer: str, value: Any) -> Any:
|
||||
"""Aggregate a fan-in value per the join `reducer`. `value` is the list the fan-out
|
||||
children accumulated into a state key (via that key's `add` reducer)."""
|
||||
if not isinstance(value, list):
|
||||
# A lone/non-list value has nothing to aggregate: concat/first/last are identity, and
|
||||
# merge on a single dict is identity too.
|
||||
return value
|
||||
if reducer == "first":
|
||||
return value[0] if value else None
|
||||
if reducer == "last":
|
||||
return value[-1] if value else None
|
||||
if reducer == "merge":
|
||||
merged: dict[str, Any] = {}
|
||||
for v in value:
|
||||
if isinstance(v, dict):
|
||||
merged.update(v)
|
||||
return merged
|
||||
# concat (default): flatten one level when children each contributed a list, else the
|
||||
# already-flat accumulated list is the concatenation.
|
||||
if value and all(isinstance(v, list) for v in value):
|
||||
return [x for sub in value for x in sub]
|
||||
return value
|
||||
|
||||
|
||||
def join_factory(config: dict, ctx: CompileContext):
|
||||
"""Converge parallel branches.
|
||||
|
||||
Default (no `input_key`): a passthrough convergence marker - the fan-out results are
|
||||
already aggregated by their state key's own `add` reducer, so the node just re-joins the
|
||||
branches. When `input_key` is set the node ACTIVELY re-aggregates that key per `reducer`
|
||||
(concat|merge|first|last) and writes the result to `output_key` (default = `input_key`),
|
||||
so the reducer choice is honored rather than merely advisory (audit F1).
|
||||
|
||||
Note: if `output_key` equals a key that uses an `add` reducer, the reduced value would be
|
||||
appended (not replaced) - point `output_key` at a `last`-reducer field for a clean rewrite.
|
||||
"""
|
||||
reducer = config.get("reducer", "concat")
|
||||
input_key = config.get("input_key")
|
||||
output_key = config.get("output_key") or input_key
|
||||
|
||||
def _node(state: dict) -> dict:
|
||||
if not input_key:
|
||||
return {} # convergence marker; aggregation is done by the state-key reducer
|
||||
return {output_key: _apply_join_reducer(reducer, state.get(input_key))}
|
||||
|
||||
return _node
|
||||
|
||||
|
||||
def router_targets(config: dict) -> list[str]:
|
||||
cases = config.get("cases", {}) or {}
|
||||
targets = list(cases.values())
|
||||
if config.get("default"):
|
||||
targets.append(config["default"])
|
||||
return sorted(set(targets))
|
||||
|
||||
|
||||
register(
|
||||
NodeSpec(
|
||||
type="start",
|
||||
schema_id="forge/nodes/start",
|
||||
input_ports=[],
|
||||
output_ports=[Port(id="out", io_type="control", direction="out")],
|
||||
factory=start_factory,
|
||||
category="flow",
|
||||
label="Start",
|
||||
description="Entry marker",
|
||||
summarize=lambda c: [],
|
||||
)
|
||||
)
|
||||
|
||||
register(
|
||||
NodeSpec(
|
||||
type="end",
|
||||
schema_id="forge/nodes/end",
|
||||
input_ports=[Port(id="in", io_type="control", direction="in")],
|
||||
output_ports=[],
|
||||
factory=end_factory,
|
||||
category="flow",
|
||||
label="End",
|
||||
description="Terminal node",
|
||||
summarize=lambda c: [],
|
||||
)
|
||||
)
|
||||
|
||||
register(
|
||||
NodeSpec(
|
||||
type="loop",
|
||||
schema_id="forge/nodes/loop",
|
||||
input_ports=[Port(id="in", io_type="any", direction="in")],
|
||||
output_ports=[Port(id="out", io_type="any", direction="out")],
|
||||
factory=loop_factory,
|
||||
allows_cycle=True,
|
||||
category="flow",
|
||||
label="Loop",
|
||||
description="Iterate the body until a condition/max-iterations (writes _loop=continue/done).",
|
||||
summarize=lambda c: [f"max {c.get('max_iter', 10)}", c.get("condition", "")[:32]],
|
||||
)
|
||||
)
|
||||
|
||||
register(
|
||||
NodeSpec(
|
||||
type="parallel_fanout",
|
||||
schema_id="forge/nodes/parallel_fanout",
|
||||
input_ports=[Port(id="in", io_type="json", direction="in")],
|
||||
output_ports=[Port(id="out", io_type="control", direction="out", many=True)],
|
||||
factory=lambda c, ctx: _passthrough,
|
||||
category="flow",
|
||||
label="Parallel Fanout",
|
||||
description="Map a list: run a child node per item in parallel (Send).",
|
||||
summarize=lambda c: [f"over {c.get('over', '-')} → {c.get('child_node', '-')}"],
|
||||
)
|
||||
)
|
||||
|
||||
register(
|
||||
NodeSpec(
|
||||
type="join",
|
||||
schema_id="forge/nodes/join",
|
||||
input_ports=[Port(id="in", io_type="control", direction="in", many=True)],
|
||||
output_ports=[Port(id="out", io_type="json", direction="out")],
|
||||
factory=join_factory,
|
||||
category="flow",
|
||||
label="Join",
|
||||
description="Converge parallel branches; optionally re-aggregate a key via the reducer.",
|
||||
summarize=lambda c: [
|
||||
f"reducer · {c.get('reducer', 'concat')}"
|
||||
+ (f" · {c.get('input_key')}→{c.get('output_key') or c.get('input_key')}" if c.get("input_key") else "")
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
register(
|
||||
NodeSpec(
|
||||
type="subworkflow",
|
||||
schema_id="forge/nodes/subworkflow",
|
||||
input_ports=[Port(id="in", io_type="any", direction="in")],
|
||||
output_ports=[Port(id="out", io_type="any", direction="out")],
|
||||
factory=subworkflow_factory,
|
||||
category="flow",
|
||||
label="Subworkflow",
|
||||
description="Run another workflow as a reusable component.",
|
||||
summarize=lambda c: [f"→ {c.get('workflow_id', '-')}"],
|
||||
)
|
||||
)
|
||||
|
||||
register(
|
||||
NodeSpec(
|
||||
type="router",
|
||||
schema_id="forge/nodes/router",
|
||||
input_ports=[Port(id="in", io_type="any", direction="in")],
|
||||
output_ports=[Port(id="out", io_type="control", direction="out", many=True)],
|
||||
factory=router_factory,
|
||||
category="flow",
|
||||
label="Router",
|
||||
description="Conditional branch",
|
||||
summarize=lambda c: [
|
||||
f"expression · {c.get('expression', '')}" + (" · multi" if c.get("multi") else ""),
|
||||
" · ".join(list((c.get('cases') or {}).keys()) + (["default"] if c.get("default") else [])),
|
||||
],
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,205 @@
|
||||
"""`llm` node - a single model call, no tool loop (Doc 2 §7).
|
||||
|
||||
Reads the conversation from `messages`, optionally prepends the configured prompt
|
||||
as a system message, and appends the model's reply. Structured output binds the
|
||||
response_format schema. The prompt is a TemplateString: `{{state.<key>}}` tokens are
|
||||
rendered against the current run state (same engine the webhook/emit nodes use), so a
|
||||
"rewrite this value" prompt sees the actual state instead of the literal placeholder.
|
||||
Per-run secrets (`ctx.*`) are intentionally NOT exposed to the prompt (LLM-visible).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.engine.models import resolve_model
|
||||
from forge.engine.registry import NodeSpec, Port, register
|
||||
|
||||
|
||||
def llm_factory(config: dict, ctx: CompileContext):
|
||||
model = resolve_model(config["model"], ctx, config.get("model_params"))
|
||||
prompt = config.get("prompt")
|
||||
rf = (config.get("response_format") or {})
|
||||
structured_schema = rf.get("schema") if rf.get("mode") == "structured" else None
|
||||
runnable = model.with_structured_output(structured_schema) if structured_schema else model
|
||||
|
||||
async def _node(state: dict) -> dict:
|
||||
from langchain_core.messages import SystemMessage
|
||||
|
||||
from forge.auth_providers.templates import render_template
|
||||
|
||||
msgs = list(state.get("messages") or [])
|
||||
# Render {{state.*}} tokens in the prompt against the live run state. A whole-string
|
||||
# single token can resolve to a non-str value; coerce so SystemMessage content is text.
|
||||
rendered = render_template(prompt, {"state": dict(state)}) if isinstance(prompt, str) and prompt else prompt
|
||||
if rendered is not None and not isinstance(rendered, str):
|
||||
rendered = str(rendered)
|
||||
input_msgs: list[Any] = ([SystemMessage(content=rendered)] if rendered else []) + msgs
|
||||
result = await runnable.ainvoke(input_msgs)
|
||||
if structured_schema:
|
||||
# Structured result is not a message; surface it on a conventional channel.
|
||||
return {"structured_response": result}
|
||||
return {"messages": [result]}
|
||||
|
||||
return _node
|
||||
|
||||
|
||||
register(
|
||||
NodeSpec(
|
||||
type="llm",
|
||||
schema_id="forge/nodes/llm",
|
||||
input_ports=[Port(id="in", io_type="text", direction="in")],
|
||||
output_ports=[Port(id="out", io_type="text", direction="out")],
|
||||
factory=llm_factory,
|
||||
category="model_tools",
|
||||
label="LLM",
|
||||
description="Single model call",
|
||||
summarize=lambda c: [str(c.get("model", "-")), "single call"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def classifier_factory(config: dict, ctx: CompileContext):
|
||||
"""Classify the latest user message into one (or, with `multi_label`, several) of N
|
||||
labels (structured output) and write the result to a state key (default `intent`)
|
||||
for a downstream router.
|
||||
|
||||
This is the docs' routing pattern: "use structured output for the routing decision,
|
||||
then add_conditional_edges". With `multi_label: true` the node writes a LIST of
|
||||
labels - pair it with a router configured `multi: true` to fan out to every matching
|
||||
branch in parallel (multi-intent questions). A label outside the configured set (or
|
||||
a model failure) falls back to a naive keyword match, else writes nothing - the
|
||||
router's default/Else path then handles it.
|
||||
"""
|
||||
labels = [str(label) for label in (config.get("labels") or []) if str(label).strip()]
|
||||
output_key = config.get("output_key", "intent")
|
||||
instructions = config.get("instructions", "")
|
||||
multi = bool(config.get("multi_label", False))
|
||||
# Intent classification is high-volume + low-stakes - default to the provider's
|
||||
# cheapest model (overridable via config.model) instead of the workflow default.
|
||||
model_ref = config.get("model")
|
||||
if not model_ref:
|
||||
from forge.engine.models import cheap_model_for_credentials
|
||||
|
||||
model_ref = cheap_model_for_credentials(getattr(ctx, "provider_credentials", None))
|
||||
model = resolve_model(model_ref, ctx, config.get("model_params"))
|
||||
|
||||
if multi:
|
||||
schema = {
|
||||
"title": "Classification",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "enum": labels or ["other"]},
|
||||
"minItems": 1,
|
||||
"description": "EVERY label that applies to the user's message.",
|
||||
}
|
||||
},
|
||||
"required": ["labels"],
|
||||
}
|
||||
else:
|
||||
schema = {
|
||||
"title": "Classification",
|
||||
"type": "object",
|
||||
"properties": {"label": {"type": "string", "enum": labels or ["other"]}},
|
||||
"required": ["label"],
|
||||
}
|
||||
|
||||
def _text_of(message: Any) -> str:
|
||||
content = message.get("content") if isinstance(message, dict) else getattr(message, "content", "")
|
||||
return content if isinstance(content, str) else str(content or "")
|
||||
|
||||
def _role_of(message: Any) -> str | None:
|
||||
return message.get("role") if isinstance(message, dict) else getattr(message, "type", None)
|
||||
|
||||
def _keyword_fallback(text: str) -> list[str]:
|
||||
q = text.lower()
|
||||
return [
|
||||
label for label in labels
|
||||
if label.lower() in q or label.lower().replace("_", " ") in q
|
||||
]
|
||||
|
||||
def _recent_context(msgs: list[Any], max_messages: int = 8, max_chars: int = 4000) -> str:
|
||||
lines: list[str] = []
|
||||
for m in msgs[-max_messages:]:
|
||||
role = _role_of(m) or "message"
|
||||
text = _text_of(m).strip()
|
||||
if text:
|
||||
lines.append(f"{role}: {text}")
|
||||
return "\n".join(lines)[-max_chars:]
|
||||
|
||||
async def _node(state: dict) -> dict:
|
||||
msgs = state.get("messages") or []
|
||||
query = ""
|
||||
for m in reversed(msgs):
|
||||
role = _role_of(m)
|
||||
content = _text_of(m)
|
||||
if role in ("human", "user") and content:
|
||||
query = content
|
||||
break
|
||||
if not query or not labels:
|
||||
return {}
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
task = (
|
||||
f"Classify the latest user message into EVERY label that applies (one or more) from: {', '.join(labels)}.\n"
|
||||
if multi else
|
||||
f"Classify the latest user message into exactly one of these labels: {', '.join(labels)}.\n"
|
||||
)
|
||||
# Fold a BOUNDED slice of recent turns into the prompt instead of resending the entire
|
||||
# message history each call. Classification only needs the latest message plus a little
|
||||
# context for follow-ups ("what about Delhi?"); an unbounded history makes this node
|
||||
# progressively slower and pricier as the conversation grows, and duplicated the query
|
||||
# (restated in the prompt AND resent as messages). One bounded message keeps it flat.
|
||||
context = _recent_context(list(msgs))
|
||||
prompt = (
|
||||
task
|
||||
+ "Use the recent conversation context to resolve follow-up or elliptical messages "
|
||||
"(for example, 'what about Delhi?' should inherit the prior topic). "
|
||||
"Return only the structured label(s).\n"
|
||||
+ (f"{instructions}\n" if instructions else "")
|
||||
+ (f"\nRecent conversation:\n{context}\n" if context else "")
|
||||
+ f"\nLatest user message: {query}"
|
||||
)
|
||||
chosen: list[str] = []
|
||||
try:
|
||||
# Send the prompt as a HUMAN turn, not a system message: Gemini routes a lone system
|
||||
# message to system_instruction and then rejects the call with "contents are required"
|
||||
# (empty contents), and Anthropic requires a leading user turn. A single human message
|
||||
# carries the whole self-contained classification prompt and works across providers.
|
||||
res = await model.with_structured_output(schema).ainvoke([HumanMessage(content=prompt)])
|
||||
if multi:
|
||||
raw = res.get("labels") if isinstance(res, dict) else getattr(res, "labels", None)
|
||||
chosen = [str(x) for x in (raw or []) if str(x) in labels]
|
||||
else:
|
||||
label = res.get("label") if isinstance(res, dict) else getattr(res, "label", None)
|
||||
chosen = [label] if label in labels else []
|
||||
except Exception: # noqa: BLE001 - offline/fake models can't do structured output
|
||||
chosen = []
|
||||
if not chosen:
|
||||
chosen = _keyword_fallback(query) or _keyword_fallback(_recent_context(list(msgs)))
|
||||
if not chosen:
|
||||
return {}
|
||||
return {output_key: chosen if multi else chosen[0]}
|
||||
|
||||
return _node
|
||||
|
||||
|
||||
register(
|
||||
NodeSpec(
|
||||
type="classifier",
|
||||
schema_id="forge/nodes/classifier",
|
||||
input_ports=[Port(id="in", io_type="text", direction="in")],
|
||||
output_ports=[Port(id="out", io_type="text", direction="out")],
|
||||
factory=classifier_factory,
|
||||
category="model_tools",
|
||||
label="Classifier",
|
||||
description="Intent classification",
|
||||
summarize=lambda c: [
|
||||
f"→ {c.get('output_key', 'intent')}" + (" · multi" if c.get("multi_label") else ""),
|
||||
" · ".join((c.get("labels") or [])[:4]) or "no labels",
|
||||
],
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Knowledge node: retrieval - RAG document search + curated Q&A lookup in one node.
|
||||
|
||||
It reads the latest user message from `messages` and appends a SystemMessage with the
|
||||
retrieved document chunks and/or matching Q&A pairs (grounding for a downstream agent).
|
||||
Document search (include_docs) and Q&A lookup (include_qa) are toggled independently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.engine.registry import NodeSpec, Port, register
|
||||
from forge.knowledge.embeddings import DEFAULT_MIN_SCORE, DEFAULT_RERANK_MIN_SCORE
|
||||
from forge.knowledge.store import citation_for
|
||||
|
||||
_KB_TAG = "forge_kb"
|
||||
|
||||
|
||||
def _passes_floor(h: Any, min_score: float, hybrid: bool) -> bool:
|
||||
"""Cosine-floor a hit on the RIGHT scale. In hybrid mode Hit.score is the fused RANK (top≈1.0),
|
||||
NOT cosine, so threshold Hit.vector_score (the real cosine) instead; a BM25-only hit has no
|
||||
cosine (vector_score None) and is kept (a strong exact-term match shouldn't be floored out).
|
||||
In vector-only mode Hit.score IS the cosine."""
|
||||
cos = h.vector_score if hybrid else h.score
|
||||
return cos is None or cos >= min_score
|
||||
|
||||
|
||||
def _kb_removals(msgs: list[Any]) -> list[Any]:
|
||||
"""RemoveMessage for every prior retrieval system-message (tagged), so KB context is
|
||||
EPHEMERAL - only the current turn's chunks stay in history instead of accumulating
|
||||
(which otherwise grows cost every turn on a checkpointed thread)."""
|
||||
from langchain_core.messages import RemoveMessage
|
||||
|
||||
out: list[Any] = []
|
||||
for m in msgs or []:
|
||||
ak = m.get("additional_kwargs") if isinstance(m, dict) else (getattr(m, "additional_kwargs", {}) or {})
|
||||
if ak and ak.get(_KB_TAG):
|
||||
mid = m.get("id") if isinstance(m, dict) else getattr(m, "id", None)
|
||||
if mid:
|
||||
out.append(RemoveMessage(id=mid))
|
||||
return out
|
||||
|
||||
|
||||
def _last_user_text(msgs: list[Any]) -> str:
|
||||
for m in reversed(msgs or []):
|
||||
role = m.get("role") if isinstance(m, dict) else getattr(m, "type", None)
|
||||
content = m.get("content") if isinstance(m, dict) else getattr(m, "content", "")
|
||||
if role in ("human", "user", None) and content:
|
||||
return content if isinstance(content, str) else str(content)
|
||||
if msgs:
|
||||
m = msgs[-1]
|
||||
c = m.get("content") if isinstance(m, dict) else getattr(m, "content", "")
|
||||
return c if isinstance(c, str) else str(c or "")
|
||||
return ""
|
||||
|
||||
|
||||
def retrieval_factory(cfg: dict, ctx: CompileContext):
|
||||
top_k = cfg.get("top_k", 5)
|
||||
# RAG document search is on by default. Turn it off for a Q&A-only retrieval node:
|
||||
# include_docs=False + include_qa=True makes this node subsume the old qa_lookup.
|
||||
include_docs = cfg.get("include_docs", True)
|
||||
# Hybrid = fuse BM25 lexical ranking with vector search (RRF). Opt-in; default vector-only.
|
||||
hybrid = bool(cfg.get("hybrid", False))
|
||||
# Rerank = a second-stage local cross-encoder over a larger shortlist, keeping the best
|
||||
# top_k. Opt-in; big accuracy win at some latency. rerank_top_n sizes the shortlist.
|
||||
rerank = bool(cfg.get("rerank", False))
|
||||
rerank_top_n = cfg.get("rerank_top_n")
|
||||
source_filter = cfg.get("source_filter") or None
|
||||
# Restrict retrieval to sources in these folders (resolved to source ids at run
|
||||
# time, so it composes with source_filter and needs no Chroma re-ingest).
|
||||
folders = cfg.get("folders") or None
|
||||
# Cosine floor so wildly off-topic queries surface no context (the grounded agent then refuses
|
||||
# instead of answering from the nearest chunk). Calibrated to the default BGE embedder (~0.6;
|
||||
# related pairs ~0.75+, unrelated ~0.4-0.5) - lower it for a hosted model on a smaller scale.
|
||||
min_score = cfg.get("min_score", DEFAULT_MIN_SCORE)
|
||||
# When rerank is on, Hit.score is the cross-encoder sigmoid (a DIFFERENT scale from cosine), so
|
||||
# min_score can't apply; this separate floor lets an off-topic reranked query still yield empty.
|
||||
rerank_min_score = cfg.get("rerank_min_score", DEFAULT_RERANK_MIN_SCORE)
|
||||
# Optional MMR diversity pass (trade a little relevance for less-redundant top_k).
|
||||
mmr = bool(cfg.get("mmr", False))
|
||||
mmr_lambda = cfg.get("mmr_lambda", 0.5)
|
||||
include_qa = cfg.get("include_qa", False)
|
||||
qa_threshold = cfg.get("qa_threshold", 0.3)
|
||||
qa_top_k = cfg.get("qa_top_k", 3)
|
||||
# Only include Q&A pairs of these kinds/categories (empty = all kinds).
|
||||
qa_kinds = cfg.get("qa_kinds") or None
|
||||
# When nothing relevant is found, inject an explicit note so a grounded agent knows
|
||||
# to say it doesn't have the answer rather than fall back to model world-knowledge.
|
||||
announce_empty = cfg.get("announce_empty", False)
|
||||
# When set, also write "yes"/"no" (found anything?) to this state key so a downstream
|
||||
# router can branch found → agent / not-found → escalation. Optional; key must be
|
||||
# declared in workflow state (the canvas auto-declares node-written keys).
|
||||
route_key = cfg.get("route_key")
|
||||
|
||||
async def _node(state: dict) -> dict:
|
||||
from langchain_core.messages import SystemMessage
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.services.knowledge import KnowledgeService
|
||||
|
||||
query = _last_user_text(state.get("messages") or [])
|
||||
if not query:
|
||||
return {route_key: "no"} if route_key else {}
|
||||
|
||||
blocks: list[str] = []
|
||||
async with SessionLocal() as s:
|
||||
# Embed the query ONCE and reuse the vector for both doc search and Q&A
|
||||
# scoring. Skip entirely when neither source is enabled, so a node configured
|
||||
# to do nothing makes no embedding API call.
|
||||
embedder = qvec = None
|
||||
if include_docs or include_qa:
|
||||
try:
|
||||
embedder = await KnowledgeService.embedder_for_project(s, ctx.tenant_id, ctx.project_id)
|
||||
qvec = await embedder.aembed_query(query)
|
||||
except Exception as e: # noqa: BLE001 - embedder unavailable
|
||||
from forge.util.metrics import incr
|
||||
|
||||
incr("retrieval.embedder_unavailable", detail=str(e))
|
||||
embedder = qvec = None
|
||||
try:
|
||||
hits = await KnowledgeService.search(
|
||||
s, ctx.tenant_id, ctx.project_id, query, top_k=top_k,
|
||||
source_ids=source_filter, folders=folders, embedder=embedder, embedding=qvec,
|
||||
hybrid=hybrid, rerank=rerank, rerank_top_n=rerank_top_n, mmr=mmr, mmr_lambda=mmr_lambda,
|
||||
) if (include_docs and embedder) else []
|
||||
except Exception: # noqa: BLE001 - store not ready / empty
|
||||
hits = []
|
||||
# Apply the relevance floor on the correct scale. Reranked hits use the cross-encoder
|
||||
# sigmoid floor (rerank_min_score); otherwise the cosine floor (min_score) on the real
|
||||
# cosine - Hit.vector_score in hybrid mode (Hit.score there is the fused rank, not cosine).
|
||||
if rerank:
|
||||
if rerank_min_score is not None:
|
||||
hits = [h for h in hits if h.score >= rerank_min_score]
|
||||
elif min_score is not None:
|
||||
hits = [h for h in hits if _passes_floor(h, min_score, hybrid)]
|
||||
for i, h in enumerate(hits):
|
||||
cite = citation_for(h.metadata)
|
||||
label = f"Doc {i + 1} · {cite}" if cite else f"Doc {i + 1}"
|
||||
blocks.append(f"[{label}] {h.text}")
|
||||
if include_qa:
|
||||
try:
|
||||
qa = await KnowledgeService.top_qa(
|
||||
s, ctx.tenant_id, ctx.project_id, query, top_k=qa_top_k, threshold=qa_threshold,
|
||||
kinds=qa_kinds, embedder=embedder, embedding=qvec,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
qa = []
|
||||
for q in qa:
|
||||
blocks.append(f"[FAQ] Q: {q['question']}\nA: {q['answer']}")
|
||||
|
||||
out: dict[str, Any] = {}
|
||||
if route_key:
|
||||
out[route_key] = "yes" if blocks else "no"
|
||||
removals = _kb_removals(state.get("messages") or [])
|
||||
if not blocks:
|
||||
if announce_empty:
|
||||
out["messages"] = [*removals, SystemMessage(
|
||||
content="KNOWLEDGE BASE: no relevant entries were found for the user's question.",
|
||||
additional_kwargs={_KB_TAG: True},
|
||||
)]
|
||||
elif removals:
|
||||
out["messages"] = removals
|
||||
return out
|
||||
|
||||
ctxt = "\n\n".join(blocks)
|
||||
out["messages"] = [*removals, SystemMessage(
|
||||
content="KNOWLEDGE BASE context for the user's question:\n" + ctxt,
|
||||
additional_kwargs={_KB_TAG: True},
|
||||
)]
|
||||
return out
|
||||
|
||||
return _node
|
||||
|
||||
|
||||
def _retrieval_summary(c: dict) -> list[str]:
|
||||
"""Glanceable canvas lines: which sources this retrieval node pulls from."""
|
||||
lines: list[str] = []
|
||||
if c.get("include_docs", True):
|
||||
flags = ("" if not c.get("hybrid") else " · hybrid") + ("" if not c.get("rerank") else " · rerank")
|
||||
lines.append(f"docs top_k {c.get('top_k', 5)}{flags}")
|
||||
if c.get("include_qa"):
|
||||
lines.append(f"Q&A top_k {c.get('qa_top_k', 3)}")
|
||||
return lines or ["no sources enabled"]
|
||||
|
||||
|
||||
register(NodeSpec(
|
||||
type="retrieval", schema_id="forge/nodes/retrieval",
|
||||
input_ports=[Port(id="in", io_type="text", direction="in")],
|
||||
output_ports=[Port(id="out", io_type="json", direction="out")],
|
||||
factory=retrieval_factory, category="knowledge", label="Retrieval",
|
||||
description="RAG document search + Q&A lookup",
|
||||
summarize=_retrieval_summary,
|
||||
))
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Trigger nodes - event-driven entry points (Phase 3).
|
||||
|
||||
A trigger node is the workflow's entry: the dispatcher (webhook route / scheduler /
|
||||
inbound email) creates a run whose `input` already carries the inbound
|
||||
message, then the graph runs from the trigger node onward. At compile time a trigger is
|
||||
a passthrough, exactly like `start`; its config drives the DISPATCHER (which path /
|
||||
schedule / mailbox), not the graph body.
|
||||
|
||||
Registering them as node types means they appear in the palette, validate via schema,
|
||||
and the validator's "reachable from entry / path to END" rules apply unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from forge.engine.context import CompileContext
|
||||
from forge.engine.registry import NodeSpec, Port, register
|
||||
|
||||
TRIGGER_TYPES = ("webhook_in", "schedule", "email_in", "app_event")
|
||||
|
||||
|
||||
def _passthrough_factory(config: dict, ctx: CompileContext):
|
||||
def _node(state: dict) -> dict:
|
||||
return {}
|
||||
|
||||
return _node
|
||||
|
||||
|
||||
_out = [Port(id="out", io_type="control", direction="out")]
|
||||
|
||||
register(NodeSpec(
|
||||
type="webhook_in", schema_id="forge/nodes/trigger_webhook",
|
||||
input_ports=[], output_ports=_out, factory=_passthrough_factory,
|
||||
category="triggers", label="Webhook", description="Run when an external system POSTs to this workflow's hook URL.",
|
||||
summarize=lambda c: ["inbound webhook", "signed" if c.get("require_signature") else "unsigned"],
|
||||
))
|
||||
register(NodeSpec(
|
||||
type="schedule", schema_id="forge/nodes/trigger_schedule",
|
||||
input_ports=[], output_ports=_out, factory=_passthrough_factory,
|
||||
category="triggers", label="Schedule", description="Run on a recurring schedule (cron or every N minutes).",
|
||||
summarize=lambda c: [c.get("cron") or (f"every {c.get('every_minutes', '-')} min")],
|
||||
))
|
||||
register(NodeSpec(
|
||||
type="email_in", schema_id="forge/nodes/trigger_email",
|
||||
input_ports=[], output_ports=_out, factory=_passthrough_factory,
|
||||
category="triggers", label="Email", description="Run when an email arrives in the connected mailbox; optionally reply.",
|
||||
summarize=lambda c: [c.get("mailbox") or "inbound email", "reply" if c.get("reply", True) else "no reply"],
|
||||
))
|
||||
register(NodeSpec(
|
||||
type="app_event", schema_id="forge/nodes/trigger_app_event",
|
||||
input_ports=[], output_ports=_out, factory=_passthrough_factory,
|
||||
category="triggers", label="App Event", description="Poll an external source; run once per new item.",
|
||||
summarize=lambda c: [str(c.get("poll_url", ""))[:32], f"every {c.get('interval_minutes', 5)} min"],
|
||||
))
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Optional arq/Redis job queue for OFFLOADED run execution (audit P1).
|
||||
|
||||
The interactive SSE path stays inline (it must stream tokens to the caller). The
|
||||
*non-interactive* paths - webhook + schedule triggers - don't need a synchronous reply,
|
||||
so when a Redis/arq worker is configured they're enqueued instead of run on the web
|
||||
process, which keeps long LLM runs off the request thread and gives backpressure +
|
||||
retries via the worker tier. With no Redis configured, `enqueue_run` returns False and
|
||||
the caller runs inline (the always-available default).
|
||||
|
||||
Safe to import anywhere: arq is imported lazily inside the functions, so the API process
|
||||
doesn't need the `workers` extra installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
from forge.config import settings
|
||||
|
||||
log = logging.getLogger("forge.queue")
|
||||
|
||||
_pool = None
|
||||
|
||||
|
||||
def queue_enabled() -> bool:
|
||||
if not settings.redis_url:
|
||||
return False
|
||||
try:
|
||||
import arq # noqa: F401
|
||||
except Exception: # noqa: BLE001 - arq (workers extra) not installed
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _get_pool():
|
||||
global _pool
|
||||
if _pool is None:
|
||||
from arq import create_pool
|
||||
from arq.connections import RedisSettings
|
||||
|
||||
_pool = await create_pool(RedisSettings.from_dsn(settings.redis_url))
|
||||
return _pool
|
||||
|
||||
|
||||
async def enqueue_run(run_id: str, tenant_id: str, project_id: str | None = None) -> bool:
|
||||
"""Enqueue a run for the worker. Returns False (caller runs inline) when no queue is
|
||||
configured or enqueue fails - so a Redis blip degrades to inline, never drops the run."""
|
||||
if not queue_enabled():
|
||||
return False
|
||||
try:
|
||||
pool = await _get_pool()
|
||||
await pool.enqueue_job("run_job", run_id, tenant_id, project_id)
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("failed to enqueue run %s; falling back to inline execution", run_id)
|
||||
return False
|
||||
|
||||
|
||||
async def close_pool() -> None:
|
||||
global _pool
|
||||
if _pool is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await _pool.aclose()
|
||||
_pool = None
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP + SSE routers."""
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Agent preset endpoints (CRUD + validate)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
|
||||
from forge.schemas.dto import (
|
||||
AgentCreate,
|
||||
AgentOut,
|
||||
AgentUpdate,
|
||||
ExportIn,
|
||||
ImportIn,
|
||||
ImportReport,
|
||||
ValidateOut,
|
||||
)
|
||||
from forge.services.agents import AgentService
|
||||
from forge.services.portability import PortabilityService
|
||||
from forge.services.versions import safe_snapshot
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/agents", tags=["agents"])
|
||||
|
||||
|
||||
@router.post("/export")
|
||||
async def export_agents(project_id: str, body: ExportIn, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id)):
|
||||
"""Serialize the selected agent presets (full config) into a downloadable bundle."""
|
||||
return await PortabilityService.export(session, tenant_id, project_id, "agent", body.ids)
|
||||
|
||||
|
||||
@router.post("/import", response_model=ImportReport)
|
||||
async def import_agents(project_id: str, body: ImportIn, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(require_role("editor"))):
|
||||
"""Create agent presets from an uploaded bundle in THIS project (auto-renamed on collision)."""
|
||||
if body.type not in (None, "agent"):
|
||||
raise HTTPException(422, f"This file contains '{body.type}' exports — import it from the matching screen.")
|
||||
try:
|
||||
return await PortabilityService.import_bundle(session, tenant_id, project_id, body.model_dump(), author=user)
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
|
||||
|
||||
@router.get("", response_model=list[AgentOut])
|
||||
async def list_agents(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
return await AgentService.list(session, tenant_id, project_id)
|
||||
|
||||
|
||||
@router.post("", response_model=AgentOut, status_code=201)
|
||||
async def create_agent(project_id: str, body: AgentCreate, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(require_role("editor"))):
|
||||
agent = await AgentService.create(session, tenant_id, project_id, name=body.name, config=body.config,
|
||||
created_by=user.id, created_by_email=user.email)
|
||||
await safe_snapshot(session, "agent", agent, author=user)
|
||||
return agent
|
||||
|
||||
|
||||
@router.get("/{agent_id}", response_model=AgentOut)
|
||||
async def get_agent(project_id: str, agent_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
agent = await AgentService.get(session, tenant_id, agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
return agent
|
||||
|
||||
|
||||
@router.patch("/{agent_id}", response_model=AgentOut)
|
||||
async def update_agent(project_id: str, agent_id: str, body: AgentUpdate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
user: CurrentUser = Depends(require_role("editor"))):
|
||||
agent = await AgentService.get(session, tenant_id, agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
agent = await AgentService.update(session, agent, name=body.name, config=body.config)
|
||||
await safe_snapshot(session, "agent", agent, author=user)
|
||||
return agent
|
||||
|
||||
|
||||
@router.delete("/{agent_id}", status_code=204)
|
||||
async def delete_agent(project_id: str, agent_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
agent = await AgentService.get(session, tenant_id, agent_id)
|
||||
if agent is None:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
await AgentService.delete(session, agent)
|
||||
|
||||
|
||||
@router.post("/validate", response_model=ValidateOut)
|
||||
async def validate_agent(project_id: str, body: AgentCreate):
|
||||
errors = AgentService.validate(body.config)
|
||||
return ValidateOut(valid=not errors, errors=errors)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Forge Assistant endpoint - streams the meta-agent's narration + actions over SSE."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from forge.deps import current_tenant_id, get_checkpointer, get_session
|
||||
from forge.services.assistant import AssistantService
|
||||
from forge.services.projects import ProjectService
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/assistant", tags=["assistant"])
|
||||
|
||||
SSE_HEADERS = {
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
|
||||
|
||||
class AssistantMessage(BaseModel):
|
||||
role: str = "user"
|
||||
content: str
|
||||
|
||||
|
||||
class AssistantIn(BaseModel):
|
||||
# Preferred: one new message + a stable thread_id (the checkpointer holds history,
|
||||
# todos, and files for the thread). `messages` remains for back-compat - when
|
||||
# thread_id is absent the full transcript is replayed statelessly.
|
||||
message: str | None = None
|
||||
thread_id: str | None = None
|
||||
messages: list[AssistantMessage] = []
|
||||
|
||||
|
||||
class AssistantResumeIn(BaseModel):
|
||||
thread_id: str
|
||||
decision: str = "approve" # approve | reject
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
async def assistant_stream(
|
||||
project_id: str,
|
||||
body: AssistantIn,
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
checkpointer=Depends(get_checkpointer),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
# Ownership check (audit H4): the assistant runs tools scoped to the URL's project_id; without
|
||||
# this, a caller in tenant A could target tenant B's project and read/write its resources. 404
|
||||
# (not 403) so a foreign project id is indistinguishable from a missing one.
|
||||
if await ProjectService.get(session, tenant_id, project_id) is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "project not found")
|
||||
if body.message:
|
||||
messages = [{"role": "user", "content": body.message}]
|
||||
else:
|
||||
messages = [m.model_dump() for m in body.messages]
|
||||
|
||||
async def event_gen():
|
||||
async for frame in AssistantService.stream(
|
||||
tenant_id=tenant_id, project_id=project_id, messages=messages,
|
||||
thread_id=body.thread_id, checkpointer=checkpointer,
|
||||
):
|
||||
yield {"event": frame["event"], "data": json.dumps(frame["data"], default=str)}
|
||||
|
||||
return EventSourceResponse(event_gen(), headers=SSE_HEADERS)
|
||||
|
||||
|
||||
@router.post("/resume")
|
||||
async def assistant_resume(
|
||||
project_id: str,
|
||||
body: AssistantResumeIn,
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
checkpointer=Depends(get_checkpointer),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Resume a paused assistant thread (HITL approval for destructive tools)."""
|
||||
if await ProjectService.get(session, tenant_id, project_id) is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "project not found")
|
||||
|
||||
async def event_gen():
|
||||
async for frame in AssistantService.resume(
|
||||
tenant_id=tenant_id, project_id=project_id, thread_id=body.thread_id,
|
||||
decision=body.decision, checkpointer=checkpointer,
|
||||
):
|
||||
yield {"event": frame["event"], "data": json.dumps(frame["data"], default=str)}
|
||||
|
||||
return EventSourceResponse(event_gen(), headers=SSE_HEADERS)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Audit-log read + export endpoints (admin+).
|
||||
|
||||
The audit trail is APPEND-ONLY: these endpoints only ever read it (there is deliberately no
|
||||
update/delete route). See services/audit.py and infra/postgres_rls.sql for the invariant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
|
||||
from forge.services.audit import AuditService
|
||||
|
||||
router = APIRouter(prefix="/v1/audit", tags=["audit"])
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
async def metrics(_: CurrentUser = Depends(require_role("admin"))):
|
||||
"""In-process resilience counters (swallowed-failure visibility)."""
|
||||
from forge.util.metrics import snapshot
|
||||
|
||||
return snapshot()
|
||||
|
||||
|
||||
def _row(r) -> dict:
|
||||
return {
|
||||
"id": r.id, "action": r.action, "actor_email": r.actor_email, "actor_id": r.actor_id,
|
||||
"resource_type": r.resource_type, "resource_id": r.resource_id, "project_id": r.project_id,
|
||||
"ip": r.ip, "status": r.status, "meta": r.meta, "at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_audit(
|
||||
response: Response,
|
||||
project_id: str | None = None,
|
||||
action: str | None = None,
|
||||
actor: str | None = Query(None, description="match actor_email or actor_id"),
|
||||
start: datetime | None = Query(None, description="only entries at/after this time (ISO 8601)"),
|
||||
end: datetime | None = Query(None, description="only entries at/before this time (ISO 8601)"),
|
||||
cursor: str | None = Query(None, description="opaque keyset cursor from a prior page"),
|
||||
limit: int = 200,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("admin")),
|
||||
):
|
||||
"""Filtered, keyset-paginated audit list (newest first). Returns a JSON array (unchanged
|
||||
shape); the opaque cursor for the next page, when there is one, is in the `X-Next-Cursor`
|
||||
response header (finding g)."""
|
||||
try:
|
||||
rows, next_cursor = await AuditService.query(
|
||||
session, tenant_id, action=action, actor=actor, project_id=project_id,
|
||||
start=start, end=end, cursor=cursor, limit=min(limit, 1000),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
if next_cursor:
|
||||
response.headers["X-Next-Cursor"] = next_cursor
|
||||
return [_row(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_audit(
|
||||
project_id: str | None = None,
|
||||
action: str | None = None,
|
||||
actor: str | None = None,
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("admin")),
|
||||
):
|
||||
"""Stream ALL matching audit rows as newline-delimited JSON (oldest first), paged internally
|
||||
so a large export never buffers the whole table (finding g)."""
|
||||
|
||||
async def _gen():
|
||||
async for r in AuditService.export(session, tenant_id, action=action, actor=actor,
|
||||
project_id=project_id, start=start, end=end):
|
||||
yield json.dumps(_row(r), default=str) + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
_gen(), media_type="application/x-ndjson",
|
||||
headers={"Content-Disposition": "attachment; filename=forge-audit.ndjson"},
|
||||
)
|
||||
@@ -0,0 +1,593 @@
|
||||
"""Auth + team-management endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.config import settings
|
||||
from forge.deps import (
|
||||
CurrentUser,
|
||||
client_ip,
|
||||
current_tenant_id,
|
||||
get_current_user,
|
||||
get_session,
|
||||
require_role,
|
||||
)
|
||||
from forge.security import (
|
||||
TokenError,
|
||||
create_email_verification_token,
|
||||
create_invite_token,
|
||||
create_password_reset_token,
|
||||
decode_token,
|
||||
is_revoked,
|
||||
revoke,
|
||||
revoke_user_tokens,
|
||||
tokens_revoked_after,
|
||||
totp_provisioning_uri,
|
||||
)
|
||||
from forge.services.audit import AuditService
|
||||
from forge.services.auth import AuthError, AuthService
|
||||
from forge.util.mailer import send_email
|
||||
from forge.util.ratelimit import rate_limiter
|
||||
|
||||
router = APIRouter(prefix="/v1/auth", tags=["auth"])
|
||||
team_router = APIRouter(prefix="/v1/team", tags=["team"])
|
||||
workspace_router = APIRouter(prefix="/v1/workspace", tags=["workspace"])
|
||||
apikeys_router = APIRouter(prefix="/v1/api-keys", tags=["api-keys"])
|
||||
|
||||
|
||||
def _auth_throttle(request: Request, email: str | None = None) -> None:
|
||||
"""Ceiling on the unauthenticated auth endpoints - brute-force / credential-stuffing guard
|
||||
(finding a). Two dimensions: a STRICT per-email bucket (per-account brute-force) and a LOOSER
|
||||
per-IP bucket at 10x (credential-stuffing / DoS across many accounts). 429 when either is
|
||||
empty. `auth_rate_limit_per_minute` is the per-email rate; 0 disables both."""
|
||||
rate = settings.auth_rate_limit_per_minute
|
||||
if rate <= 0:
|
||||
return
|
||||
ip = client_ip(request) or "unknown"
|
||||
checks = [(f"auth:ip:{ip}", rate * 10)]
|
||||
if email:
|
||||
checks.append((f"auth:email:{email.strip().lower()}", rate))
|
||||
for key, per_min in checks:
|
||||
if not rate_limiter.allow(key, rate=per_min, per=60):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "too many attempts; please slow down and try again")
|
||||
|
||||
|
||||
class RegisterIn(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8)
|
||||
workspace_name: str | None = None
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
# plain str (not EmailStr): login accepts whatever was registered, incl. local addresses.
|
||||
email: str
|
||||
password: str
|
||||
workspace_id: str | None = None
|
||||
totp_code: str | None = None # required only when the account has TOTP MFA enabled
|
||||
|
||||
|
||||
class RefreshIn(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class LogoutIn(BaseModel):
|
||||
# Optional: the refresh token to revoke. Unauthenticated (possession of the signed token is
|
||||
# itself the proof) so it works even after the access token has expired.
|
||||
refresh_token: str | None = None
|
||||
|
||||
|
||||
class InviteIn(BaseModel):
|
||||
email: EmailStr
|
||||
role: str = "editor"
|
||||
password: str | None = Field(default=None, min_length=8)
|
||||
|
||||
|
||||
class AcceptInviteIn(BaseModel):
|
||||
token: str
|
||||
password: str = Field(min_length=8)
|
||||
|
||||
|
||||
def _invite_link(token: str) -> str:
|
||||
return f"{settings.public_console_url.rstrip('/')}/?invite={token}"
|
||||
|
||||
|
||||
async def _send_invite_email(*, to: str, link: str, inviter: str | None, role: str) -> bool:
|
||||
who = f"{inviter} " if inviter else ""
|
||||
subject = "You've been invited to Forge"
|
||||
body = (
|
||||
f"{who}invited you to join their Forge workspace as a {role}.\n\n"
|
||||
f"Set your password and get started:\n{link}\n\n"
|
||||
"This link expires in 7 days. If you weren't expecting this, you can ignore this email."
|
||||
)
|
||||
html = (
|
||||
f"<p>{who}invited you to join their Forge workspace as a <b>{role}</b>.</p>"
|
||||
f'<p><a href="{link}">Set your password and get started →</a></p>'
|
||||
"<p style='color:#888;font-size:13px'>This link expires in 7 days. "
|
||||
"If you weren't expecting this, you can ignore this email.</p>"
|
||||
)
|
||||
return await send_email(to=to, subject=subject, body=body, html=html)
|
||||
|
||||
|
||||
class MemberPatch(BaseModel):
|
||||
role: str | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class PasswordIn(BaseModel):
|
||||
password: str = Field(min_length=8)
|
||||
|
||||
|
||||
def _user_out(u) -> dict:
|
||||
return {"id": u.id, "email": u.email, "role": u.role, "status": u.status, "tenant_id": u.tenant_id}
|
||||
|
||||
|
||||
@router.post("/register", status_code=201)
|
||||
async def register(body: RegisterIn, request: Request, session: AsyncSession = Depends(get_session)):
|
||||
if not settings.allow_open_signup:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "open signup is disabled; ask an admin for an invite")
|
||||
_auth_throttle(request, str(body.email))
|
||||
try:
|
||||
user = await AuthService.register(
|
||||
session, email=str(body.email), password=body.password, workspace_name=body.workspace_name
|
||||
)
|
||||
except AuthError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
await AuditService.log(tenant_id=user.tenant_id, action="auth.register", actor_id=user.id,
|
||||
actor_email=user.email, ip=client_ip(request))
|
||||
return {**AuthService.tokens_for(user), "user": _user_out(user)}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(body: LoginIn, request: Request, session: AsyncSession = Depends(get_session)):
|
||||
_auth_throttle(request, str(body.email))
|
||||
try:
|
||||
user = await AuthService.authenticate(
|
||||
session, email=str(body.email), password=body.password, tenant_id=body.workspace_id,
|
||||
)
|
||||
except AuthError as e:
|
||||
await AuditService.log(tenant_id="-", action="auth.login", actor_email=str(body.email),
|
||||
ip=client_ip(request), status="denied", meta={"reason": str(e)})
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(e)) from e
|
||||
# Second factor (only enforced when the account has TOTP enabled).
|
||||
if not await AuthService.check_login_totp(session, user, body.totp_code):
|
||||
await AuditService.log(tenant_id=user.tenant_id, action="auth.login", actor_id=user.id,
|
||||
actor_email=user.email, ip=client_ip(request), status="denied",
|
||||
meta={"reason": "totp_required_or_invalid"})
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "a valid authenticator code is required", {"WWW-Authenticate": "TOTP"})
|
||||
await AuditService.log(tenant_id=user.tenant_id, action="auth.login", actor_id=user.id,
|
||||
actor_email=user.email, ip=client_ip(request))
|
||||
return {**AuthService.tokens_for(user), "user": _user_out(user)}
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh(body: RefreshIn, request: Request, session: AsyncSession = Depends(get_session)):
|
||||
_auth_throttle(request)
|
||||
# Decode WITHOUT the revoked-check so a reused (already-rotated) refresh token is detected
|
||||
# rather than looking like a plain invalid token (finding d).
|
||||
try:
|
||||
claims = decode_token(body.refresh_token, expected_type="refresh", check_revoked=False)
|
||||
except TokenError as e:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(e)) from e
|
||||
uid = claims.get("sub", "")
|
||||
# Reuse detection: a rotated refresh token presented again is a theft signal -> sign the
|
||||
# whole user out (revoke every session) and refuse.
|
||||
if is_revoked(claims.get("jti")):
|
||||
revoke_user_tokens(uid)
|
||||
await AuditService.log(tenant_id=claims.get("tid", "-"), action="auth.refresh_reuse",
|
||||
actor_id=uid, ip=client_ip(request), status="denied")
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "refresh token reuse detected; all sessions signed out")
|
||||
if tokens_revoked_after(claims):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session has been signed out")
|
||||
user = await AuthService.get_user(session, uid)
|
||||
if user is None or user.status != "active":
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "account not found or disabled")
|
||||
# Rotate: denylist the presented refresh jti (self-expiring at its own exp) and mint a fresh
|
||||
# access + refresh pair.
|
||||
revoke(claims.get("jti"), exp=claims.get("exp"))
|
||||
return AuthService.tokens_for(user)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(body: LogoutIn):
|
||||
"""Revoke the presented refresh token (this device/session). Unauthenticated: possession of
|
||||
the signed token is the proof, so it works even after the access token expires (finding d)."""
|
||||
if body.refresh_token:
|
||||
try:
|
||||
claims = decode_token(body.refresh_token, expected_type="refresh", check_revoked=False)
|
||||
revoke(claims.get("jti"), exp=claims.get("exp"))
|
||||
except TokenError:
|
||||
pass # already invalid/expired - nothing to revoke
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/logout-all")
|
||||
async def logout_all(request: Request, user: CurrentUser = Depends(get_current_user)):
|
||||
"""Sign out every session for the current user (all devices) by advancing their revocation
|
||||
cutoff so all previously-issued access/refresh tokens are rejected (finding d)."""
|
||||
revoke_user_tokens(user.id)
|
||||
await AuditService.log(tenant_id=user.tenant_id, action="auth.logout_all", actor_id=user.id,
|
||||
actor_email=user.email, ip=client_ip(request))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(user: CurrentUser = Depends(get_current_user)):
|
||||
return {"id": user.id, "email": user.email, "role": user.role, "tenant_id": user.tenant_id,
|
||||
"is_fallback": user.is_fallback}
|
||||
|
||||
|
||||
@router.post("/set-password")
|
||||
async def set_my_password(body: PasswordIn, user: CurrentUser = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session)):
|
||||
if user.is_fallback:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "cannot set a password for the fallback dev user")
|
||||
try:
|
||||
await AuthService.set_password(session, user_id=user.id, password=body.password)
|
||||
except AuthError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --- team management (admin+) ---
|
||||
@team_router.get("/members")
|
||||
async def list_members(session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("admin"))):
|
||||
return [_user_out(u) for u in await AuthService.list_members(session, tenant_id)]
|
||||
|
||||
|
||||
@team_router.post("/members", status_code=201)
|
||||
async def invite_member(body: InviteIn, request: Request, session: AsyncSession = Depends(get_session),
|
||||
admin: CurrentUser = Depends(require_role("admin"))):
|
||||
try:
|
||||
user = await AuthService.invite(session, tenant_id=admin.tenant_id, email=str(body.email),
|
||||
role=body.role, password=body.password)
|
||||
except AuthError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
await AuditService.log(tenant_id=admin.tenant_id, action="team.invite", actor_id=admin.id,
|
||||
actor_email=admin.email, resource_type="user", resource_id=user.id,
|
||||
ip=client_ip(request), meta={"email": user.email, "role": user.role})
|
||||
|
||||
out = _user_out(user)
|
||||
# No password set => emailed-invite flow: mint a redeemable link and try to send it.
|
||||
# If SMTP isn't configured, hand the link back so the admin can share it manually.
|
||||
if user.status == "invited":
|
||||
token = create_invite_token(user_id=user.id, tenant_id=user.tenant_id)
|
||||
link = _invite_link(token)
|
||||
out["email_sent"] = await _send_invite_email(to=user.email, link=link, inviter=admin.email, role=user.role)
|
||||
if not out["email_sent"]:
|
||||
out["invite_url"] = link
|
||||
else:
|
||||
out["email_sent"] = False # admin set a temp password; share it out-of-band
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/invite-info")
|
||||
async def invite_info(token: str, session: AsyncSession = Depends(get_session)):
|
||||
"""Public: validate an invite token and return who it's for (for the accept screen)."""
|
||||
try:
|
||||
claims = decode_token(token, expected_type="invite")
|
||||
except TokenError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "this invite link is invalid or has expired") from e
|
||||
user = await AuthService.get_user(session, claims.get("sub", ""))
|
||||
if user is None or user.status != "invited":
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "this invite has already been used or was revoked")
|
||||
return {"email": user.email, "role": user.role}
|
||||
|
||||
|
||||
@router.post("/accept-invite")
|
||||
async def accept_invite(body: AcceptInviteIn, request: Request, session: AsyncSession = Depends(get_session)):
|
||||
"""Public: redeem an invite token, set the first password, and log the user in."""
|
||||
_auth_throttle(request)
|
||||
try:
|
||||
claims = decode_token(body.token, expected_type="invite")
|
||||
except TokenError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "this invite link is invalid or has expired") from e
|
||||
try:
|
||||
user = await AuthService.accept_invite(
|
||||
session, user_id=claims.get("sub", ""), tenant_id=claims.get("tid", ""), password=body.password
|
||||
)
|
||||
except AuthError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
await AuditService.log(tenant_id=user.tenant_id, action="auth.accept_invite", actor_id=user.id,
|
||||
actor_email=user.email, ip=client_ip(request))
|
||||
return {**AuthService.tokens_for(user), "user": _user_out(user)}
|
||||
|
||||
|
||||
@team_router.patch("/members/{user_id}")
|
||||
async def update_member(user_id: str, body: MemberPatch, request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
admin: CurrentUser = Depends(require_role("admin"))):
|
||||
try:
|
||||
user = None
|
||||
if body.role is not None:
|
||||
user = await AuthService.set_role(session, tenant_id=admin.tenant_id, user_id=user_id, role=body.role)
|
||||
if body.status is not None:
|
||||
user = await AuthService.set_status(session, tenant_id=admin.tenant_id, user_id=user_id, status=body.status)
|
||||
except AuthError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "nothing to update")
|
||||
await AuditService.log(tenant_id=admin.tenant_id, action="team.update", actor_id=admin.id,
|
||||
actor_email=admin.email, resource_type="user", resource_id=user_id,
|
||||
ip=client_ip(request), meta=body.model_dump(exclude_none=True))
|
||||
return _user_out(user)
|
||||
|
||||
|
||||
@team_router.delete("/members/{user_id}")
|
||||
async def deactivate_member(user_id: str, request: Request, session: AsyncSession = Depends(get_session),
|
||||
admin: CurrentUser = Depends(require_role("admin"))):
|
||||
if user_id == admin.id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "you cannot deactivate yourself")
|
||||
try:
|
||||
await AuthService.set_status(session, tenant_id=admin.tenant_id, user_id=user_id, status="disabled")
|
||||
except AuthError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
await AuditService.log(tenant_id=admin.tenant_id, action="team.deactivate", actor_id=admin.id,
|
||||
actor_email=admin.email, resource_type="user", resource_id=user_id, ip=client_ip(request))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --- password reset (finding j) ---
|
||||
class PasswordResetRequestIn(BaseModel):
|
||||
email: EmailStr
|
||||
workspace_id: str | None = None
|
||||
|
||||
|
||||
class PasswordResetIn(BaseModel):
|
||||
token: str
|
||||
password: str = Field(min_length=8)
|
||||
|
||||
|
||||
async def _send_link_email(*, to: str, subject: str, intro: str, link: str, expires: str) -> bool:
|
||||
body = f"{intro}\n\n{link}\n\nThis link expires in {expires}. If you weren't expecting this, ignore this email."
|
||||
html = (f"<p>{intro}</p><p><a href=\"{link}\">Continue →</a></p>"
|
||||
f"<p style='color:#888;font-size:13px'>This link expires in {expires}. "
|
||||
"If you weren't expecting this, you can ignore this email.</p>")
|
||||
return await send_email(to=to, subject=subject, body=body, html=html)
|
||||
|
||||
|
||||
@router.post("/request-password-reset")
|
||||
async def request_password_reset(body: PasswordResetRequestIn, request: Request,
|
||||
session: AsyncSession = Depends(get_session)):
|
||||
"""Public: email a signed reset link. Always returns ok (never reveals whether the address
|
||||
exists). No SMTP configured => the link is returned so an admin/dev can use it."""
|
||||
_auth_throttle(request, str(body.email))
|
||||
user = await AuthService.get_by_email(
|
||||
session, str(body.email), tenant_id=body.workspace_id,
|
||||
)
|
||||
if user and user.status != "disabled":
|
||||
token = create_password_reset_token(user_id=user.id, tenant_id=user.tenant_id)
|
||||
link = f"{settings.public_console_url.rstrip('/')}/?reset={token}"
|
||||
sent = await _send_link_email(to=user.email, subject="Reset your Forge password",
|
||||
intro="Use the link below to set a new password.",
|
||||
link=link, expires="1 hour")
|
||||
await AuditService.log(tenant_id=user.tenant_id, action="auth.request_password_reset",
|
||||
actor_id=user.id, actor_email=user.email, ip=client_ip(request))
|
||||
if not sent:
|
||||
return {"ok": True, "reset_url": link}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_password(body: PasswordResetIn, request: Request,
|
||||
session: AsyncSession = Depends(get_session)):
|
||||
"""Public: redeem a reset token, set a new password, and sign out all existing sessions.
|
||||
Does NOT auto-login (user re-authenticates, re-prompting MFA)."""
|
||||
_auth_throttle(request)
|
||||
try:
|
||||
claims = decode_token(body.token, expected_type="pwreset")
|
||||
except TokenError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "this reset link is invalid or has expired") from e
|
||||
try:
|
||||
user = await AuthService.reset_password(
|
||||
session, user_id=claims.get("sub", ""), tenant_id=claims.get("tid", ""), password=body.password
|
||||
)
|
||||
except AuthError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
revoke(claims.get("jti"), exp=claims.get("exp")) # one-time use
|
||||
await AuditService.log(tenant_id=user.tenant_id, action="auth.reset_password", actor_id=user.id,
|
||||
actor_email=user.email, ip=client_ip(request))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --- email verification (finding j) ---
|
||||
class TokenIn(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
@router.post("/request-email-verification")
|
||||
async def request_email_verification(request: Request, user: CurrentUser = Depends(get_current_user)):
|
||||
if user.is_fallback or not user.email:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "no verifiable email for this account")
|
||||
token = create_email_verification_token(user_id=user.id, tenant_id=user.tenant_id)
|
||||
link = f"{settings.public_console_url.rstrip('/')}/?verify_email={token}"
|
||||
sent = await _send_link_email(to=user.email, subject="Verify your Forge email",
|
||||
intro="Confirm this email address for your Forge account.",
|
||||
link=link, expires="3 days")
|
||||
return {"ok": True} if sent else {"ok": True, "verify_url": link}
|
||||
|
||||
|
||||
@router.post("/verify-email")
|
||||
async def verify_email(body: TokenIn, session: AsyncSession = Depends(get_session)):
|
||||
try:
|
||||
claims = decode_token(body.token, expected_type="email_verify")
|
||||
except TokenError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "this verification link is invalid or has expired") from e
|
||||
try:
|
||||
await AuthService.mark_email_verified(session, user_id=claims.get("sub", ""), tenant_id=claims.get("tid", ""))
|
||||
except AuthError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
revoke(claims.get("jti"), exp=claims.get("exp"))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --- TOTP MFA (finding j; optional per user) ---
|
||||
class TotpCodeIn(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
@router.post("/mfa/totp/enroll")
|
||||
async def totp_enroll(user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_session)):
|
||||
"""Generate a TOTP secret (NOT yet active - confirm a code to enable). Returns the secret
|
||||
and an otpauth:// URL for an authenticator-app QR."""
|
||||
if user.is_fallback:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "cannot enroll MFA for the dev fallback user")
|
||||
u = await AuthService.get_user(session, user.id)
|
||||
if u is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found")
|
||||
secret = await AuthService.enroll_totp(session, u)
|
||||
return {"secret": secret, "otpauth_url": totp_provisioning_uri(secret, account=u.email, issuer=settings.app_name)}
|
||||
|
||||
|
||||
@router.post("/mfa/totp/confirm")
|
||||
async def totp_confirm(body: TotpCodeIn, user: CurrentUser = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session)):
|
||||
u = await AuthService.get_user(session, user.id)
|
||||
if u is None or not await AuthService.confirm_totp(session, user=u, code=body.code):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid authenticator code")
|
||||
return {"ok": True, "mfa_enabled": True}
|
||||
|
||||
|
||||
@router.post("/mfa/totp/disable")
|
||||
async def totp_disable(body: TotpCodeIn, user: CurrentUser = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session)):
|
||||
"""Disable MFA. Requires a current code so a hijacked session can't silently turn it off."""
|
||||
u = await AuthService.get_user(session, user.id)
|
||||
if u is None or not await AuthService.check_login_totp(session, u, body.code):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "invalid authenticator code")
|
||||
await AuthService.disable_totp(session, u)
|
||||
return {"ok": True, "mfa_enabled": False}
|
||||
|
||||
|
||||
# --- workspace (tenant) administration (finding k) ---
|
||||
class WorkspaceUpdateIn(BaseModel):
|
||||
name: str | None = None
|
||||
plan: str | None = None
|
||||
# Quota / limit overrides merged into tenant.settings (max_runs_per_day, max_cost_per_day_usd,
|
||||
# max_tokens_per_day, project_limits, reset_tz, ...). Merged, not replaced.
|
||||
settings: dict | None = None
|
||||
|
||||
|
||||
class WorkspaceDeleteIn(BaseModel):
|
||||
# Must equal the workspace name - a deliberate, un-fat-fingerable confirmation for a
|
||||
# destructive, irreversible cascade.
|
||||
confirm_name: str
|
||||
|
||||
|
||||
def _tenant_out(t) -> dict:
|
||||
return {"id": t.id, "name": t.name, "plan": t.plan, "settings": t.settings or {}}
|
||||
|
||||
|
||||
@workspace_router.get("")
|
||||
async def get_workspace(session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("admin"))):
|
||||
from forge.models import Tenant
|
||||
|
||||
t = await session.get(Tenant, tenant_id)
|
||||
if t is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "workspace not found")
|
||||
return _tenant_out(t)
|
||||
|
||||
|
||||
@workspace_router.patch("")
|
||||
async def update_workspace(body: WorkspaceUpdateIn, request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
owner: CurrentUser = Depends(require_role("owner"))):
|
||||
"""Owner-only: rename, change plan, or adjust quota limits (finding k)."""
|
||||
try:
|
||||
t = await AuthService.update_workspace(
|
||||
session, tenant_id=owner.tenant_id, name=body.name, plan=body.plan, settings_patch=body.settings
|
||||
)
|
||||
except AuthError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
await AuditService.log(tenant_id=owner.tenant_id, action="workspace.update", actor_id=owner.id,
|
||||
actor_email=owner.email, ip=client_ip(request),
|
||||
meta=body.model_dump(exclude_none=True))
|
||||
return _tenant_out(t)
|
||||
|
||||
|
||||
@workspace_router.delete("", status_code=204)
|
||||
async def delete_workspace(body: WorkspaceDeleteIn, request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
owner: CurrentUser = Depends(require_role("owner"))):
|
||||
"""Owner-only, name-confirmed, cascading workspace deletion (finding k). Irreversible."""
|
||||
from forge.models import Tenant
|
||||
|
||||
t = await session.get(Tenant, owner.tenant_id)
|
||||
if t is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "workspace not found")
|
||||
if body.confirm_name != t.name:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "confirm_name does not match the workspace name")
|
||||
await AuditService.log(tenant_id=owner.tenant_id, action="workspace.delete", actor_id=owner.id,
|
||||
actor_email=owner.email, ip=client_ip(request), meta={"name": t.name})
|
||||
await AuthService.delete_workspace(
|
||||
session, tenant_id=owner.tenant_id,
|
||||
checkpointer=getattr(request.app.state, "checkpointer", None),
|
||||
)
|
||||
|
||||
|
||||
# --- API keys (finding h) ---
|
||||
class ApiKeyCreateIn(BaseModel):
|
||||
name: str
|
||||
role: str = "editor"
|
||||
ttl_days: int | None = None
|
||||
|
||||
|
||||
def _apikey_out(k, *, plaintext: str | None = None) -> dict:
|
||||
out = {"id": k.id, "name": k.name, "role": k.role, "status": k.status, "prefix": k.prefix,
|
||||
"last_used_at": k.last_used_at.isoformat() if k.last_used_at else None,
|
||||
"expires_at": k.expires_at.isoformat() if k.expires_at else None}
|
||||
if plaintext is not None:
|
||||
out["key"] = plaintext # shown ONCE at creation
|
||||
return out
|
||||
|
||||
|
||||
@apikeys_router.get("")
|
||||
async def list_api_keys(session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("admin"))):
|
||||
from forge.services.apikeys import ApiKeyService
|
||||
|
||||
return [_apikey_out(k) for k in await ApiKeyService.list(session, tenant_id)]
|
||||
|
||||
|
||||
@apikeys_router.post("", status_code=201)
|
||||
async def create_api_key(body: ApiKeyCreateIn, request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
admin: CurrentUser = Depends(require_role("admin"))):
|
||||
from forge.services.apikeys import ApiKeyService
|
||||
|
||||
# An admin must not be able to mint a key MORE privileged than themselves.
|
||||
from forge.services.auth import role_at_least
|
||||
|
||||
if not role_at_least(admin.role, body.role):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "cannot create a key more privileged than your role")
|
||||
try:
|
||||
key, plaintext = await ApiKeyService.create(
|
||||
session, tenant_id=admin.tenant_id, name=body.name, role=body.role,
|
||||
created_by=admin.id, ttl_days=body.ttl_days,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
await AuditService.log(tenant_id=admin.tenant_id, action="apikey.create", actor_id=admin.id,
|
||||
actor_email=admin.email, resource_type="api_key", resource_id=key.id,
|
||||
ip=client_ip(request), meta={"name": key.name, "role": key.role})
|
||||
return _apikey_out(key, plaintext=plaintext)
|
||||
|
||||
|
||||
@apikeys_router.delete("/{key_id}", status_code=204)
|
||||
async def revoke_api_key(key_id: str, request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
admin: CurrentUser = Depends(require_role("admin"))):
|
||||
from forge.services.apikeys import ApiKeyService
|
||||
|
||||
if not await ApiKeyService.revoke(session, tenant_id=admin.tenant_id, key_id=key_id):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "API key not found")
|
||||
await AuditService.log(tenant_id=admin.tenant_id, action="apikey.revoke", actor_id=admin.id,
|
||||
actor_email=admin.email, resource_type="api_key", resource_id=key_id,
|
||||
ip=client_ip(request))
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Auth Provider endpoints (CRUD + /test)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
|
||||
from forge.schemas.contracts import validate_against_id
|
||||
from forge.schemas.dto import (
|
||||
AuthProviderCreate,
|
||||
AuthProviderOut,
|
||||
AuthProviderUpdate,
|
||||
AuthTestIn,
|
||||
UserConnectionIn,
|
||||
)
|
||||
from forge.services.auth_providers import AuthProviderService
|
||||
from forge.services.versions import safe_snapshot
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/auth-providers", tags=["auth-providers"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[AuthProviderOut])
|
||||
async def list_aps(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
return await AuthProviderService.list(session, tenant_id, project_id)
|
||||
|
||||
|
||||
@router.post("", response_model=AuthProviderOut, status_code=201)
|
||||
async def create_ap(project_id: str, body: AuthProviderCreate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
user: CurrentUser = Depends(require_role("editor"))):
|
||||
cfg = {**body.config, "name": body.name, "kind": body.kind}
|
||||
if body.credentials_ref:
|
||||
cfg["credentials_ref"] = body.credentials_ref
|
||||
errors = validate_against_id(cfg, "forge/auth_provider")
|
||||
if errors:
|
||||
raise HTTPException(422, detail={"errors": errors})
|
||||
ap = await AuthProviderService.create(session, tenant_id, project_id, name=body.name, kind=body.kind, config=body.config, credentials_ref=body.credentials_ref)
|
||||
await safe_snapshot(session, "auth_provider", ap, author=user)
|
||||
return ap
|
||||
|
||||
|
||||
@router.get("/{ap_id}", response_model=AuthProviderOut)
|
||||
async def get_ap(project_id: str, ap_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
ap = await AuthProviderService.get(session, tenant_id, ap_id)
|
||||
if ap is None:
|
||||
raise HTTPException(404, "Auth provider not found")
|
||||
return ap
|
||||
|
||||
|
||||
@router.patch("/{ap_id}", response_model=AuthProviderOut)
|
||||
async def update_ap(project_id: str, ap_id: str, body: AuthProviderUpdate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
user: CurrentUser = Depends(require_role("editor"))):
|
||||
ap = await AuthProviderService.get(session, tenant_id, ap_id)
|
||||
if ap is None:
|
||||
raise HTTPException(404, "Auth provider not found")
|
||||
if body.config is not None:
|
||||
cfg = {**body.config, "name": body.name or ap.name, "kind": body.kind or ap.kind}
|
||||
if body.credentials_ref:
|
||||
cfg["credentials_ref"] = body.credentials_ref
|
||||
errors = validate_against_id(cfg, "forge/auth_provider")
|
||||
if errors:
|
||||
raise HTTPException(422, detail={"errors": errors})
|
||||
ap = await AuthProviderService.update(session, ap, name=body.name, kind=body.kind, config=body.config, credentials_ref=body.credentials_ref)
|
||||
await safe_snapshot(session, "auth_provider", ap, author=user)
|
||||
return ap
|
||||
|
||||
|
||||
@router.delete("/{ap_id}", status_code=204)
|
||||
async def delete_ap(project_id: str, ap_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
ap = await AuthProviderService.get(session, tenant_id, ap_id)
|
||||
if ap is None:
|
||||
raise HTTPException(404, "Auth provider not found")
|
||||
await AuthProviderService.delete(session, ap)
|
||||
|
||||
|
||||
@router.post("/{ap_id}/test")
|
||||
async def test_ap(project_id: str, ap_id: str, body: AuthTestIn, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
user: CurrentUser = Depends(require_role("editor"))):
|
||||
ap = await AuthProviderService.get(session, tenant_id, ap_id)
|
||||
if ap is None:
|
||||
raise HTTPException(404, "Auth provider not found")
|
||||
# Test AS the current user so a per-user provider resolves the tester's own connected token
|
||||
# (end_user_id keys the per-user bundle). Explicit context values still win.
|
||||
ctx = {"end_user_id": user.id, **(body.context or {})}
|
||||
return await AuthProviderService.test(tenant_id, project_id, ap, ctx)
|
||||
|
||||
|
||||
# --- Per-user connected credentials: the app owner's connect flow stores each end user's downstream
|
||||
# credential here (server-to-server, editor+), and the AuthResolver uses it to act as that user. ---
|
||||
@router.put("/{ap_id}/connections/{end_user_id}", status_code=204)
|
||||
async def set_user_connection(project_id: str, ap_id: str, end_user_id: str, body: UserConnectionIn,
|
||||
session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
ap = await AuthProviderService.get(session, tenant_id, ap_id)
|
||||
if ap is None:
|
||||
raise HTTPException(404, "Auth provider not found")
|
||||
bundle = {"access_token": body.access_token, **(body.extra or {})}
|
||||
if body.refresh_token:
|
||||
bundle["refresh_token"] = body.refresh_token
|
||||
if body.expires_at is not None:
|
||||
bundle["expires_at"] = body.expires_at
|
||||
await AuthProviderService.set_user_connection(session, tenant_id, project_id, ap, end_user_id, bundle=bundle)
|
||||
|
||||
|
||||
@router.get("/{ap_id}/connections/{end_user_id}")
|
||||
async def get_user_connection(project_id: str, ap_id: str, end_user_id: str,
|
||||
session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
ap = await AuthProviderService.get(session, tenant_id, ap_id)
|
||||
if ap is None:
|
||||
raise HTTPException(404, "Auth provider not found")
|
||||
return await AuthProviderService.get_user_connection(tenant_id, project_id, ap, end_user_id)
|
||||
|
||||
|
||||
@router.delete("/{ap_id}/connections/{end_user_id}", status_code=204)
|
||||
async def delete_user_connection(project_id: str, ap_id: str, end_user_id: str,
|
||||
session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
ap = await AuthProviderService.get(session, tenant_id, ap_id)
|
||||
if ap is None:
|
||||
raise HTTPException(404, "Auth provider not found")
|
||||
await AuthProviderService.clear_user_connection(session, tenant_id, project_id, ap, end_user_id)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Channel CRUD + the public email inbound endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.channels import email as email_ch
|
||||
from forge.config import settings
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.deps import CurrentUser, current_tenant_id, get_run_service, get_session, require_role
|
||||
from forge.services.channels import ChannelService
|
||||
from forge.services.dispatch import dispatch_message
|
||||
from forge.services.handoff import (
|
||||
HITL_META_KEY,
|
||||
HandoffService,
|
||||
interrupt_ack,
|
||||
interrupt_hitl_meta,
|
||||
interrupt_reason,
|
||||
)
|
||||
from forge.services.runs import RunService
|
||||
from forge.util.ratelimit import rate_limiter
|
||||
|
||||
log = logging.getLogger("forge.channels.router")
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/channels", tags=["channels"])
|
||||
public = APIRouter(tags=["channels"]) # unauthenticated inbound webhooks
|
||||
|
||||
|
||||
class ChannelIn(BaseModel):
|
||||
type: str
|
||||
name: str
|
||||
workflow_id: str | None = None
|
||||
config: dict = {}
|
||||
|
||||
|
||||
class ChannelPatch(BaseModel):
|
||||
name: str | None = None
|
||||
workflow_id: str | None = None
|
||||
config: dict | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
def _out(ch) -> dict:
|
||||
base = settings.public_base_url.rstrip("/")
|
||||
item = {"id": ch.id, "type": ch.type, "name": ch.name, "workflow_id": ch.workflow_id,
|
||||
"enabled": ch.enabled, "config": ch.config, "key": ch.key}
|
||||
if ch.type == "email":
|
||||
item["inbound_url"] = f"{base}/v1/channels/email/{ch.key}/inbound"
|
||||
return item
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_channels(project_id: str, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id)):
|
||||
return [_out(c) for c in await ChannelService.list(session, tenant_id, project_id)]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_channel(project_id: str, body: ChannelIn, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
try:
|
||||
ch = await ChannelService.create(session, tenant_id, project_id, type_=body.type, name=body.name,
|
||||
workflow_id=body.workflow_id, config=body.config)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from e
|
||||
return _out(ch)
|
||||
|
||||
|
||||
@router.patch("/{channel_id}")
|
||||
async def update_channel(project_id: str, channel_id: str, body: ChannelPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
ch = await ChannelService.get(session, tenant_id, channel_id)
|
||||
if not ch:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "channel not found")
|
||||
ch = await ChannelService.update(session, ch, name=body.name, workflow_id=body.workflow_id,
|
||||
config=body.config, enabled=body.enabled)
|
||||
return _out(ch)
|
||||
|
||||
|
||||
@router.delete("/{channel_id}")
|
||||
async def delete_channel(project_id: str, channel_id: str, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
ch = await ChannelService.get(session, tenant_id, channel_id)
|
||||
if not ch:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "channel not found")
|
||||
await ChannelService.delete(session, ch)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# --------------------- public inbound ---------------------
|
||||
|
||||
async def _resolve(type_: str, key: str):
|
||||
async with SessionLocal() as s:
|
||||
ch = await ChannelService.by_key(s, type_, key)
|
||||
if ch is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "unknown or disabled channel")
|
||||
workflow_id = await ChannelService.resolve_workflow_id(s, ch)
|
||||
if not workflow_id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "channel has no workflow bound")
|
||||
return ch, workflow_id
|
||||
|
||||
|
||||
async def _maybe_open_handoff(ch, result: dict, *, customer, customer_message, reply_context) -> str | None:
|
||||
"""If the run paused at an interrupt, open a HandoffRequest and return a customer-facing
|
||||
acknowledgement. Email can't resume an HITL pause inline, so ANY
|
||||
interrupt - explicit handoff OR an approval/input pause - must be tracked and acknowledged
|
||||
rather than falling through to a stale/empty partial answer (audit F8)."""
|
||||
if not result.get("interrupted"):
|
||||
return None
|
||||
interrupts = result.get("interrupts")
|
||||
reason = interrupt_reason(interrupts) or (
|
||||
"Conversation paused awaiting input/approval - a team member will follow up."
|
||||
)
|
||||
# Persist the interrupting node's allowed_decisions so the human's channel reply is coerced
|
||||
# to a valid decision before resuming (a Router keyed on approve/reject then matches) - C.
|
||||
hitl_meta = interrupt_hitl_meta(interrupts)
|
||||
ctx = dict(reply_context or {})
|
||||
if hitl_meta.get("allowed_decisions"):
|
||||
ctx[HITL_META_KEY] = {
|
||||
"allowed_decisions": hitl_meta["allowed_decisions"], "kind": hitl_meta.get("kind"),
|
||||
"timeout_default": hitl_meta.get("timeout_default"),
|
||||
}
|
||||
async with SessionLocal() as s:
|
||||
await HandoffService.create(
|
||||
s, channel=ch, tenant_id=ch.tenant_id, project_id=ch.project_id,
|
||||
workflow_id=result.get("workflow_id"), run_id=result.get("run_id"),
|
||||
thread_id=result.get("thread_id"), customer=customer, customer_message=customer_message,
|
||||
reason=reason, reply_context=ctx,
|
||||
)
|
||||
return interrupt_ack(interrupts) or "A team member will follow up with you shortly."
|
||||
|
||||
|
||||
@public.post("/v1/channels/email/{key}/inbound")
|
||||
async def email_inbound(key: str, request: Request, run_service: RunService = Depends(get_run_service)):
|
||||
ch, workflow_id = await _resolve("email", key)
|
||||
if not rate_limiter.allow(f"email:{key}", rate=120, per=60):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "rate limit exceeded")
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception: # noqa: BLE001 - provider posts form-encoded
|
||||
form = await request.form()
|
||||
payload = dict(form)
|
||||
parsed = email_ch.parse_inbound(payload)
|
||||
text = email_ch.build_input_text(parsed, include_subject=(ch.config or {}).get("include_subject", True))
|
||||
# Continue the same email thread across replies so the conversation keeps context (F6).
|
||||
conv_key = parsed.get("thread_ref") or (parsed.get("from_addr") or None)
|
||||
result = await dispatch_message(run_service, tenant_id=ch.tenant_id, project_id=ch.project_id,
|
||||
workflow_id=workflow_id, text=text, conversation_key=conv_key,
|
||||
source="channel_email")
|
||||
ack = await _maybe_open_handoff(ch, result, customer=parsed.get("from_addr"), customer_message=text, reply_context=parsed)
|
||||
reply_text = ack or result.get("answer")
|
||||
delivered = None
|
||||
if (ch.config or {}).get("reply", True) and reply_text:
|
||||
try:
|
||||
# send_reply now retries with backoff and returns whether an email was actually sent
|
||||
# (False = SMTP not configured); a failure raises so we can record it (audit E).
|
||||
delivered = await email_ch.send_reply(ch, parsed, reply_text)
|
||||
except Exception: # noqa: BLE001 - reply delivery failure shouldn't 500 the webhook
|
||||
log.warning("email reply delivery failed for channel %s", ch.id, exc_info=True)
|
||||
delivered = False
|
||||
return {"ok": True, "handoff": bool(ack), "delivered": delivered}
|
||||
@@ -0,0 +1,131 @@
|
||||
"""UI component endpoints (CRUD) - Feature 2 (generative UI).
|
||||
|
||||
A Component is a saved HTML/CSS template + declarative button actions + a JSON-Schema
|
||||
for its props. It is attached to agents like a tool (agent config["components"]) and
|
||||
rendered client-side. DTOs are defined inline since the shape is self-contained.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
|
||||
from forge.schemas.dto import ExportIn, ImportIn, ImportReport
|
||||
from forge.services.components import ComponentService
|
||||
from forge.services.portability import PortabilityService
|
||||
from forge.services.versions import safe_snapshot
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/components", tags=["components"])
|
||||
|
||||
|
||||
class ComponentCreate(BaseModel):
|
||||
# Used verbatim as the LLM tool name → must match the provider-safe identifier charset
|
||||
# (audit M3), else the call fails at request time on real providers.
|
||||
name: str = Field(pattern=r"^[a-zA-Z0-9_-]{1,64}$")
|
||||
title: str | None = None
|
||||
description: str = ""
|
||||
props_schema: dict[str, Any] = Field(default_factory=dict)
|
||||
html: str = ""
|
||||
css: str = ""
|
||||
actions: list[dict[str, Any]] = Field(default_factory=list)
|
||||
sample_props: dict[str, Any] = Field(default_factory=dict)
|
||||
kind: str = "html"
|
||||
|
||||
|
||||
class ComponentUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, pattern=r"^[a-zA-Z0-9_-]{1,64}$")
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
props_schema: dict[str, Any] | None = None
|
||||
html: str | None = None
|
||||
css: str | None = None
|
||||
actions: list[dict[str, Any]] | None = None
|
||||
sample_props: dict[str, Any] | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class ComponentOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: str
|
||||
name: str
|
||||
title: str | None = None
|
||||
description: str = ""
|
||||
props_schema: dict[str, Any] = Field(default_factory=dict)
|
||||
html: str = ""
|
||||
css: str = ""
|
||||
actions: list[dict[str, Any]] = Field(default_factory=list)
|
||||
sample_props: dict[str, Any] = Field(default_factory=dict)
|
||||
kind: str = "html"
|
||||
enabled: bool = True
|
||||
version: int = 1
|
||||
|
||||
|
||||
@router.get("", response_model=list[ComponentOut])
|
||||
async def list_components(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
return await ComponentService.list(session, tenant_id, project_id)
|
||||
|
||||
|
||||
@router.post("/export")
|
||||
async def export_components(project_id: str, body: ExportIn, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id)):
|
||||
"""Serialize the selected components (HTML/CSS/props/actions) into a downloadable bundle."""
|
||||
return await PortabilityService.export(session, tenant_id, project_id, "component", body.ids)
|
||||
|
||||
|
||||
@router.post("/import", response_model=ImportReport)
|
||||
async def import_components(project_id: str, body: ImportIn, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(require_role("editor"))):
|
||||
"""Create components from an uploaded bundle in THIS project (auto-renamed on collision)."""
|
||||
if body.type not in (None, "component"):
|
||||
raise HTTPException(422, f"This file contains '{body.type}' exports — import it from the matching screen.")
|
||||
try:
|
||||
return await PortabilityService.import_bundle(session, tenant_id, project_id, body.model_dump(), author=user)
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
|
||||
|
||||
@router.post("", response_model=ComponentOut, status_code=201)
|
||||
async def create_component(project_id: str, body: ComponentCreate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
user: CurrentUser = Depends(require_role("editor"))):
|
||||
existing = await ComponentService.list(session, tenant_id, project_id)
|
||||
if any(c.name == body.name for c in existing):
|
||||
raise HTTPException(409, f"A component named '{body.name}' already exists in this project.")
|
||||
comp = await ComponentService.create(session, tenant_id, project_id, **body.model_dump())
|
||||
await safe_snapshot(session, "component", comp, author=user)
|
||||
return comp
|
||||
|
||||
|
||||
@router.get("/{component_id}", response_model=ComponentOut)
|
||||
async def get_component(project_id: str, component_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
comp = await ComponentService.get(session, tenant_id, project_id, component_id)
|
||||
if comp is None:
|
||||
raise HTTPException(404, "Component not found")
|
||||
return comp
|
||||
|
||||
|
||||
@router.patch("/{component_id}", response_model=ComponentOut)
|
||||
async def update_component(project_id: str, component_id: str, body: ComponentUpdate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
user: CurrentUser = Depends(require_role("editor"))):
|
||||
comp = await ComponentService.get(session, tenant_id, project_id, component_id)
|
||||
if comp is None:
|
||||
raise HTTPException(404, "Component not found")
|
||||
if body.name and body.name != comp.name:
|
||||
existing = await ComponentService.list(session, tenant_id, project_id)
|
||||
if any(c.name == body.name and c.id != comp.id for c in existing):
|
||||
raise HTTPException(409, f"A component named '{body.name}' already exists in this project.")
|
||||
comp = await ComponentService.update(session, comp, **body.model_dump(exclude_unset=True))
|
||||
await safe_snapshot(session, "component", comp, author=user)
|
||||
return comp
|
||||
|
||||
|
||||
@router.delete("/{component_id}", status_code=204)
|
||||
async def delete_component(project_id: str, component_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
comp = await ComponentService.get(session, tenant_id, project_id, component_id)
|
||||
if comp is None:
|
||||
raise HTTPException(404, "Component not found")
|
||||
await ComponentService.delete(session, comp)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Self-service per-user credentials ("connections") — a connector-safe surface.
|
||||
|
||||
A per-user auth provider (config.per_user_context_keys = ["end_user_id"]) has NO shared secret;
|
||||
each end user supplies their OWN downstream token and a tool then acts as them. These routes let
|
||||
ANY logged-in user — down to the least-privileged `connector` role, who never sees the Auth
|
||||
Providers admin — list the per-user providers they must connect and set/clear their own token.
|
||||
|
||||
Deliberately separate from the `/auth-providers` admin router: it returns only minimal fields (no
|
||||
provider config / secret refs) and is gated at "any real logged-in user", so a connector needs no
|
||||
access to the auth-provider admin surface. Keyed server-side by the CALLER's user id — the same
|
||||
identity an MCP PAT resolves to — so the token a user pastes here is exactly what gets injected on
|
||||
tool calls made as them. Setting a credential ON BEHALF OF another user stays editor-gated on the
|
||||
`/auth-providers/{ap_id}/connections/{end_user_id}` routes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import CurrentUser, current_tenant_id, get_current_user, get_session
|
||||
from forge.schemas.dto import UserConnectionIn
|
||||
from forge.services.auth_providers import AuthProviderService
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/connections", tags=["connections"])
|
||||
|
||||
|
||||
def _require_real_user(user: CurrentUser) -> None:
|
||||
# A per-user credential is keyed by the caller's stable user id. Reject only shared machine
|
||||
# principals (service token / API key) which carry no per-user identity; a logged-in user
|
||||
# (any role, incl. connector) AND the auth-off dev user both key fine.
|
||||
if str(user.id).startswith(("apikey:", "service")):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "per-user credentials require a user identity")
|
||||
|
||||
|
||||
def _is_per_user(ap) -> bool:
|
||||
return "end_user_id" in ((ap.config or {}).get("per_user_context_keys") or [])
|
||||
|
||||
|
||||
async def _load_per_user(session, tenant_id: str, ap_id: str, user: CurrentUser):
|
||||
_require_real_user(user)
|
||||
ap = await AuthProviderService.get(session, tenant_id, ap_id)
|
||||
if ap is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "auth provider not found")
|
||||
if not _is_per_user(ap):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "auth provider is not per-user")
|
||||
return ap
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_my_connections(project_id: str, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(get_current_user)):
|
||||
"""The per-user providers this project defines, each with whether the CALLER has connected it.
|
||||
Minimal fields only (no config / secret refs) — safe for the connector home page."""
|
||||
_require_real_user(user)
|
||||
aps = await AuthProviderService.list(session, tenant_id, project_id)
|
||||
out = []
|
||||
for ap in aps:
|
||||
if not _is_per_user(ap):
|
||||
continue
|
||||
st = await AuthProviderService.get_user_connection(tenant_id, project_id, ap, user.id)
|
||||
out.append({"id": ap.id, "name": ap.name, "kind": ap.kind, "connected": bool(st.get("connected"))})
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/{ap_id}")
|
||||
async def my_connection_status(project_id: str, ap_id: str, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(get_current_user)):
|
||||
ap = await _load_per_user(session, tenant_id, ap_id, user)
|
||||
return await AuthProviderService.get_user_connection(tenant_id, project_id, ap, user.id)
|
||||
|
||||
|
||||
@router.put("/{ap_id}", status_code=204)
|
||||
async def set_my_connection(project_id: str, ap_id: str, body: UserConnectionIn, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(get_current_user)):
|
||||
ap = await _load_per_user(session, tenant_id, ap_id, user)
|
||||
bundle = {"access_token": body.access_token, **(body.extra or {})}
|
||||
if body.refresh_token:
|
||||
bundle["refresh_token"] = body.refresh_token
|
||||
if body.expires_at is not None:
|
||||
bundle["expires_at"] = body.expires_at
|
||||
await AuthProviderService.set_user_connection(session, tenant_id, project_id, ap, user.id, bundle=bundle)
|
||||
|
||||
|
||||
@router.delete("/{ap_id}", status_code=204)
|
||||
async def clear_my_connection(project_id: str, ap_id: str, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(get_current_user)):
|
||||
ap = await _load_per_user(session, tenant_id, ap_id, user)
|
||||
await AuthProviderService.clear_user_connection(session, tenant_id, project_id, ap, user.id)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Conversation-centric Traces endpoints.
|
||||
|
||||
A conversation = one chat session (Thread); each turn is a Trace carrying the user
|
||||
message, the AI response, and the actor (user name / "System"). This powers the
|
||||
Traces screen: sessions grouped by end user, their turns, and the filter facets.
|
||||
The per-turn span waterfall is still served by GET .../traces/{trace_id}.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import current_tenant_id, get_session, require_role
|
||||
from forge.schemas.dto import ConversationDetailOut, ConversationOut, FacetsOut, TurnOut
|
||||
from forge.services.conversations import ConversationService, summarize
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/conversations", tags=["conversations"])
|
||||
|
||||
|
||||
def _turn(t) -> TurnOut:
|
||||
return TurnOut(
|
||||
trace_id=t.id, run_id=t.run_id, source=t.source or "", user_message=t.user_message,
|
||||
ai_response=t.ai_response, status=t.status, error=t.error, latency_ms=t.latency_ms,
|
||||
total_tokens=t.total_tokens, total_cost_usd=t.total_cost_usd, started_at=t.started_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ConversationOut])
|
||||
async def list_conversations(
|
||||
project_id: str,
|
||||
actor: str | None = Query(None, description="Filter by user name (e.g. 'System', 'Unknown user')"),
|
||||
source: str | None = Query(None, description="Filter by origin (playground|api|embed|channel_*|…)"),
|
||||
status: str | None = Query(None, description="'error' or 'success'/'done'"),
|
||||
search: str | None = Query(None, description="Match user/AI message text (any turn)"),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
):
|
||||
convos = await ConversationService.list(
|
||||
session, tenant_id, project_id, actor=actor, source=source, status=status,
|
||||
search=search, limit=limit, offset=offset,
|
||||
)
|
||||
return [ConversationOut.model_validate(c) for c in convos]
|
||||
|
||||
|
||||
@router.get("/facets", response_model=FacetsOut)
|
||||
async def conversation_facets(
|
||||
project_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
):
|
||||
return await ConversationService.facets(session, tenant_id, project_id)
|
||||
|
||||
|
||||
@router.post("/purge")
|
||||
async def purge_conversations(
|
||||
project_id: str,
|
||||
older_than_days: int = Query(..., ge=0, description="Delete traces + spans older than this many days"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_admin=Depends(require_role("admin")),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
):
|
||||
"""Manual retention control (admin only). Nothing is auto-deleted."""
|
||||
removed = await ConversationService.purge_older_than(session, tenant_id, project_id, older_than_days)
|
||||
return {"removed": removed}
|
||||
|
||||
|
||||
@router.get("/{thread_id}", response_model=ConversationDetailOut)
|
||||
async def get_conversation(
|
||||
project_id: str,
|
||||
thread_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
):
|
||||
turns = await ConversationService.turns(session, tenant_id, project_id, thread_id)
|
||||
if not turns:
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
return {"conversation": summarize(thread_id, turns), "turns": [_turn(t) for t in turns]}
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Embed / identity endpoints (Phase 3b).
|
||||
|
||||
Mint a short-lived, signed SESSION TOKEN that carries a verified `end_user` for the browser
|
||||
widget. Called server-to-server by the integrator's authenticated backend (which already
|
||||
authenticated the user); the widget then sends the token on each run so Forge trusts the
|
||||
identity without trusting the browser. The signing secret never reaches the client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets as _secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import CurrentUser, get_session, require_role
|
||||
from forge.schemas.dto import EndUser
|
||||
from forge.security import create_session_token
|
||||
from forge.services.projects import ProjectService
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}", tags=["embed"])
|
||||
|
||||
|
||||
class SessionTokenIn(BaseModel):
|
||||
end_user: EndUser
|
||||
ttl_minutes: int = Field(default=30, ge=1, le=720)
|
||||
# Origins the widget is allowed to run from (carried as a claim for the widget transport;
|
||||
# see the deferred per-origin CORS work). Empty = unrestricted by token.
|
||||
origins: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SessionTokenOut(BaseModel):
|
||||
token: str
|
||||
expires_in: int # seconds
|
||||
|
||||
|
||||
@router.post("/session-tokens", response_model=SessionTokenOut)
|
||||
async def mint_session_token(
|
||||
project_id: str,
|
||||
body: SessionTokenIn,
|
||||
user: CurrentUser = Depends(require_role("editor")),
|
||||
) -> SessionTokenOut:
|
||||
token = create_session_token(
|
||||
tenant_id=user.tenant_id,
|
||||
project_id=project_id,
|
||||
end_user=body.end_user.model_dump(exclude_none=True),
|
||||
origins=body.origins,
|
||||
ttl_minutes=body.ttl_minutes,
|
||||
)
|
||||
return SessionTokenOut(token=token, expires_in=body.ttl_minutes * 60)
|
||||
|
||||
|
||||
# --- embeddable widget settings (publishable key + allowed origins + workflow) ---
|
||||
class EmbedSettingsIn(BaseModel):
|
||||
enabled: bool = True
|
||||
allowed_origins: list[str] = Field(default_factory=list)
|
||||
workflow_id: str | None = None
|
||||
|
||||
|
||||
class EmbedSettingsOut(BaseModel):
|
||||
enabled: bool
|
||||
allowed_origins: list[str]
|
||||
workflow_id: str | None = None
|
||||
publishable_key: str | None = None
|
||||
embed_src: str | None = None
|
||||
|
||||
|
||||
def _embed_out(project) -> EmbedSettingsOut:
|
||||
e = (project.config or {}).get("embed") or {}
|
||||
key = project.embed_key
|
||||
enabled = bool(e.get("enabled"))
|
||||
return EmbedSettingsOut(
|
||||
enabled=enabled,
|
||||
allowed_origins=e.get("allowed_origins") or [],
|
||||
workflow_id=e.get("workflow_id"),
|
||||
publishable_key=key,
|
||||
embed_src=(f"/embed?key={key}" if key and enabled else None),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/embed", response_model=EmbedSettingsOut)
|
||||
async def get_embed(project_id: str, session: AsyncSession = Depends(get_session), user: CurrentUser = Depends(require_role("editor"))):
|
||||
proj = await ProjectService.get(session, user.tenant_id, project_id)
|
||||
if proj is None:
|
||||
raise HTTPException(404, "Project not found")
|
||||
return _embed_out(proj)
|
||||
|
||||
|
||||
@router.put("/embed", response_model=EmbedSettingsOut)
|
||||
async def set_embed(project_id: str, body: EmbedSettingsIn, session: AsyncSession = Depends(get_session), user: CurrentUser = Depends(require_role("editor"))):
|
||||
proj = await ProjectService.get(session, user.tenant_id, project_id)
|
||||
if proj is None:
|
||||
raise HTTPException(404, "Project not found")
|
||||
if proj.embed_key is None:
|
||||
proj.embed_key = "pk_" + _secrets.token_urlsafe(24)
|
||||
cfg = dict(proj.config or {})
|
||||
cfg["embed"] = {
|
||||
"enabled": body.enabled,
|
||||
"allowed_origins": [o.strip() for o in (body.allowed_origins or []) if o.strip()],
|
||||
"workflow_id": body.workflow_id,
|
||||
}
|
||||
proj.config = cfg
|
||||
await session.commit()
|
||||
await session.refresh(proj)
|
||||
return _embed_out(proj)
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Public embed transport (Phase 3b/4).
|
||||
|
||||
Key-gated, NOT platform-JWT-authenticated endpoints the chat widget calls. The project is
|
||||
resolved by its publishable key; the widget can only run the project's configured workflow,
|
||||
as an anonymous end user or one verified by a server-minted session token. Rate-limited per
|
||||
key. The widget is served same-origin from /embed, so these are same-origin calls (no CORS).
|
||||
Embedding-site restriction is enforced by the /embed page's `frame-ancestors` CSP, set from
|
||||
the project's allowed_origins (see the web middleware).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from forge.config import settings
|
||||
from forge.deps import client_ip, get_run_service, get_session
|
||||
from forge.models import Project, Workflow
|
||||
from forge.security import TokenError, decode_token
|
||||
from forge.services.components import ComponentService
|
||||
from forge.services.runs import RunService
|
||||
from forge.util.ratelimit import rate_limiter
|
||||
|
||||
router = APIRouter(prefix="/v1/embed/{key}", tags=["embed-public"])
|
||||
|
||||
SSE_HEADERS = {"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"}
|
||||
|
||||
|
||||
def _embed_rate_limit(key: str, ip: str | None, *, per_min: int, ip_per_min: int) -> None:
|
||||
"""The publishable key is PUBLIC, so the real cost/abuse ceilings are the per-IP and
|
||||
per-key limits (audit S2). Raises 429 when either bucket is empty. per_min/ip_per_min
|
||||
of 0 => that bucket is unlimited."""
|
||||
if per_min and not rate_limiter.allow(f"embed:{key}", rate=per_min, per=60):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "too many requests; slow down")
|
||||
if ip_per_min and not rate_limiter.allow(f"embed-ip:{key}:{ip or 'unknown'}", rate=ip_per_min, per=60):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "too many requests from your network; slow down")
|
||||
|
||||
|
||||
async def _project(session: AsyncSession, key: str) -> Project:
|
||||
proj = (await session.execute(select(Project).where(Project.embed_key == key))).scalar_one_or_none()
|
||||
if proj is None or not ((proj.config or {}).get("embed") or {}).get("enabled"):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "embed not found or disabled")
|
||||
return proj
|
||||
|
||||
|
||||
async def _workflow_id(session: AsyncSession, proj: Project) -> str:
|
||||
e = (proj.config or {}).get("embed") or {}
|
||||
if e.get("workflow_id"):
|
||||
return e["workflow_id"]
|
||||
rows = (await session.execute(select(Workflow).where(Workflow.project_id == proj.id))).scalars().all()
|
||||
active = next((w for w in rows if w.status == "active"), None) or (rows[0] if rows else None)
|
||||
if active is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "no workflow configured for this embed")
|
||||
return active.id
|
||||
|
||||
|
||||
class EmbedConfigOut(BaseModel):
|
||||
name: str
|
||||
allowed_origins: list[str] = []
|
||||
|
||||
|
||||
class EmbedRunIn(BaseModel):
|
||||
input: dict | None = None
|
||||
thread_id: str | None = None
|
||||
session_token: str | None = None
|
||||
|
||||
|
||||
@router.get("/config", response_model=EmbedConfigOut)
|
||||
async def embed_config(key: str, session: AsyncSession = Depends(get_session)):
|
||||
proj = await _project(session, key)
|
||||
e = (proj.config or {}).get("embed") or {}
|
||||
# workflow_id is resolved server-side from the key on each run - the widget never needs it, so
|
||||
# don't leak the internal id to the anonymous client (audit L).
|
||||
return EmbedConfigOut(name=proj.name, allowed_origins=e.get("allowed_origins") or [])
|
||||
|
||||
|
||||
@router.get("/components")
|
||||
async def embed_components(key: str, session: AsyncSession = Depends(get_session)):
|
||||
proj = await _project(session, key)
|
||||
comps = await ComponentService.list(session, proj.tenant_id, proj.id)
|
||||
return [{"id": c.id, "name": c.name, "html": c.html, "css": c.css, "actions": c.actions} for c in comps]
|
||||
|
||||
|
||||
@router.post("/runs")
|
||||
async def embed_create_run(key: str, body: EmbedRunIn, request: Request, session: AsyncSession = Depends(get_session), run_service: RunService = Depends(get_run_service)):
|
||||
proj = await _project(session, key)
|
||||
_embed_rate_limit(
|
||||
key, client_ip(request),
|
||||
per_min=settings.embed_rate_limit_per_minute,
|
||||
ip_per_min=settings.embed_rate_limit_per_ip_per_minute,
|
||||
)
|
||||
wid = await _workflow_id(session, proj)
|
||||
# Identity: only from a verified session token (the browser can't assert it); else anonymous.
|
||||
end_user = None
|
||||
if body.session_token:
|
||||
try:
|
||||
claims = decode_token(body.session_token, expected_type="session")
|
||||
except TokenError as e:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"invalid session token: {e}") from e
|
||||
if claims.get("tid") != proj.tenant_id or claims.get("pid") != proj.id:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "session token is not valid for this embed")
|
||||
end_user = claims.get("end_user") or None
|
||||
# The public surface is anonymous and uncapped by design, so it MUST also honor the
|
||||
# tenant daily quota (audit S2) - otherwise the widget bypasses the only spend ceiling.
|
||||
from forge.services.budget import BudgetExceeded, ModelNotAllowed
|
||||
from forge.services.quota import QuotaExceeded, run_admission
|
||||
try:
|
||||
async with run_admission(session, proj.tenant_id):
|
||||
run = await run_service.create_run(
|
||||
session, tenant_id=proj.tenant_id, project_id=proj.id, workflow_id=wid,
|
||||
input=body.input or {}, thread_id=body.thread_id, end_user=end_user, source="embed",
|
||||
)
|
||||
except QuotaExceeded as e:
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, e.message) from e
|
||||
except (BudgetExceeded, ModelNotAllowed) as e:
|
||||
# Anonymous embed surface: budget/model errors are hidden as a generic 402 (no internal
|
||||
# detail to the browser end user), logged operator-side by the run machinery.
|
||||
raise HTTPException(status.HTTP_402_PAYMENT_REQUIRED, "This assistant is temporarily unavailable.") from e
|
||||
return {"id": run.id, "thread_id": run.thread_id}
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/stream")
|
||||
async def embed_stream(
|
||||
key: str, run_id: str, request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
run_service: RunService = Depends(get_run_service),
|
||||
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
|
||||
):
|
||||
proj = await _project(session, key)
|
||||
_embed_rate_limit(
|
||||
key, client_ip(request),
|
||||
per_min=0, # connection churn is bounded per-IP below, not per-key
|
||||
ip_per_min=settings.embed_stream_limit_per_ip_per_minute,
|
||||
)
|
||||
# A dropped widget connection resumes here: the browser's EventSource resends Last-Event-ID,
|
||||
# so we replay missed frames then follow live - the run itself kept executing (finding #12).
|
||||
start_from = int(last_event_id) if (last_event_id or "").isdigit() else 0
|
||||
|
||||
async def gen():
|
||||
# Scope by BOTH tenant and project (audit S1) so a publishable key can't stream
|
||||
# another project's runs; public=True hides internal error detail / operator data.
|
||||
# run_context is NOT taken from the anonymous browser: X-Forge-Context is a trusted
|
||||
# server-side caller channel, so honoring it here would let an end user forge {{ctx.*}}
|
||||
# values injected into outbound tool requests (audit M4).
|
||||
async for frame in run_service.stream(
|
||||
run_id=run_id, tenant_id=proj.tenant_id, project_id=proj.id, public=True,
|
||||
run_context=None, last_event_id=start_from,
|
||||
):
|
||||
yield {"event": frame["event"], "data": json.dumps(frame["data"], default=str), "id": frame.get("id")}
|
||||
|
||||
return EventSourceResponse(gen(), headers=SSE_HEADERS)
|
||||
|
||||
|
||||
class EmbedResumeIn(BaseModel):
|
||||
value: Any = True
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/resume")
|
||||
async def embed_resume(key: str, run_id: str, body: EmbedResumeIn, request: Request, session: AsyncSession = Depends(get_session), run_service: RunService = Depends(get_run_service)):
|
||||
"""Resume an interrupted (human-in-the-loop) run from the widget - mirrors the authed
|
||||
resume endpoint but resolves the tenant+project from the publishable key. resume() is
|
||||
scoped to this project (audit S1); the end-user identity is already bound to the thread
|
||||
(thread.meta.end_user) from the original run, so a value-only body matches the authed
|
||||
resume exactly."""
|
||||
proj = await _project(session, key)
|
||||
_embed_rate_limit(
|
||||
key, client_ip(request),
|
||||
per_min=settings.embed_rate_limit_per_minute,
|
||||
ip_per_min=settings.embed_rate_limit_per_ip_per_minute,
|
||||
)
|
||||
# public=True redacts internal error detail (M6) and returns only the final assistant message
|
||||
# (H5); run_context=None so the anonymous caller can't inject {{ctx.*}} tool values (M4).
|
||||
return await run_service.resume(run_id=run_id, tenant_id=proj.tenant_id, value=body.value, project_id=proj.id, run_context=None, public=True)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Evaluation datasets: CRUD + run (quality + regression) + persisted run history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import CurrentUser, current_tenant_id, get_run_service, get_session, require_role, run_context
|
||||
from forge.services.evals import EvalService
|
||||
from forge.services.runs import RunService
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/datasets", tags=["evals"])
|
||||
|
||||
|
||||
class DatasetIn(BaseModel):
|
||||
name: str
|
||||
workflow_id: str | None = None
|
||||
score_mode: str = "contains" # contains|exact|regex|numeric|json|embedding|judge
|
||||
items: list[dict] = []
|
||||
|
||||
|
||||
class RunIn(BaseModel):
|
||||
# Publish-time regression gate (finding F2): flag/block when the pass rate drops below the
|
||||
# dataset's previous rate, or below an absolute floor. Off by default (a plain quality run).
|
||||
regression_gate: bool = False
|
||||
min_pass_rate: float | None = None
|
||||
|
||||
|
||||
def _out(d) -> dict:
|
||||
return {"id": d.id, "name": d.name, "workflow_id": d.workflow_id, "score_mode": d.score_mode,
|
||||
"items": d.items, "n_items": len(d.items or []), "last_pass_rate": d.last_pass_rate}
|
||||
|
||||
|
||||
def _run_out(r) -> dict:
|
||||
return {"id": r.id, "created_at": r.created_at, "dataset_id": r.dataset_id, "workflow_id": r.workflow_id,
|
||||
"score_mode": r.score_mode, "status": r.status, "total": r.total, "passed": r.passed,
|
||||
"pass_rate": r.pass_rate, "prev_pass_rate": r.prev_pass_rate, "regressed": r.regressed,
|
||||
"total_tokens": r.total_tokens, "total_cost_usd": r.total_cost_usd, "meta": r.meta}
|
||||
|
||||
|
||||
def _result_out(r) -> dict:
|
||||
return {"id": r.id, "item_index": r.item_index, "input": r.input, "expected": r.expected,
|
||||
"answer": r.answer, "passed": r.passed, "score": r.score, "status": r.status,
|
||||
"reason": r.reason, "checks": r.checks}
|
||||
|
||||
|
||||
def _needs_judge(ds) -> bool:
|
||||
"""A judge model is needed if the dataset scores by judge, or any item has a judge assertion."""
|
||||
if ds.score_mode == "judge":
|
||||
return True
|
||||
return any((a.get("type") or "").lower() == "judge"
|
||||
for item in (ds.items or []) for a in (item.get("assertions") or []))
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_datasets(project_id: str, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id)):
|
||||
return [_out(d) for d in await EvalService.list(session, tenant_id, project_id)]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_dataset(project_id: str, body: DatasetIn, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
ds = await EvalService.create(session, tenant_id, project_id, name=body.name, workflow_id=body.workflow_id,
|
||||
score_mode=body.score_mode, items=body.items)
|
||||
return _out(ds)
|
||||
|
||||
|
||||
@router.patch("/{dataset_id}")
|
||||
async def update_dataset(project_id: str, dataset_id: str, body: DatasetIn, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
ds = await EvalService.get(session, tenant_id, dataset_id)
|
||||
if not ds:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "dataset not found")
|
||||
ds = await EvalService.update(session, ds, name=body.name, workflow_id=body.workflow_id,
|
||||
score_mode=body.score_mode, items=body.items)
|
||||
return _out(ds)
|
||||
|
||||
|
||||
@router.delete("/{dataset_id}")
|
||||
async def delete_dataset(project_id: str, dataset_id: str, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
ds = await EvalService.get(session, tenant_id, dataset_id)
|
||||
if not ds:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "dataset not found")
|
||||
await EvalService.delete(session, ds)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/{dataset_id}/runs")
|
||||
async def list_eval_runs(project_id: str, dataset_id: str, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id)):
|
||||
"""Persisted eval-run history for a dataset (newest first) - quality trend + regression view."""
|
||||
return [_run_out(r) for r in await EvalService.history(session, tenant_id, dataset_id)]
|
||||
|
||||
|
||||
@router.get("/{dataset_id}/runs/{eval_run_id}/results")
|
||||
async def list_eval_results(project_id: str, dataset_id: str, eval_run_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id)):
|
||||
"""Per-item outcomes for one persisted eval run (for per-example diffing)."""
|
||||
return [_result_out(r) for r in await EvalService.results(session, tenant_id, eval_run_id)]
|
||||
|
||||
|
||||
async def _prepare_run(session, *, tenant_id, project_id, dataset_id, user):
|
||||
"""Shared setup for the batch + streaming run endpoints: load the dataset, resolve a judge
|
||||
model when needed, and derive the on-behalf-of `end_user` (so per-user tool auth resolves the
|
||||
launching editor's connected credential, exactly like a Playground run - machine principals
|
||||
carry no per-user identity and run without one)."""
|
||||
ds = await EvalService.get(session, tenant_id, dataset_id)
|
||||
if not ds:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "dataset not found")
|
||||
judge_model = None
|
||||
if _needs_judge(ds):
|
||||
from forge.engine.models import resolve_model
|
||||
from forge.services.runtime import build_compile_context
|
||||
ctx = await build_compile_context(session, tenant_id=tenant_id, project_id=project_id)
|
||||
# resolve_model returns the offline fake model when the project has no real model;
|
||||
# EvalService then reports judge items as "unavailable" rather than grading against it.
|
||||
judge_model = resolve_model(ctx.default_model, ctx)
|
||||
end_user = None
|
||||
if not str(user.id).startswith(("apikey:", "service")):
|
||||
end_user = {"id": user.id, "email": user.email}
|
||||
return ds, judge_model, end_user
|
||||
|
||||
|
||||
@router.post("/{dataset_id}/run")
|
||||
async def run_dataset(project_id: str, dataset_id: str, body: RunIn | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
user: CurrentUser = Depends(require_role("editor")),
|
||||
rc: dict | None = Depends(run_context),
|
||||
run_service: RunService = Depends(get_run_service)):
|
||||
body = body or RunIn()
|
||||
ds, judge_model, end_user = await _prepare_run(session, tenant_id=tenant_id, project_id=project_id,
|
||||
dataset_id=dataset_id, user=user)
|
||||
return await EvalService.run(session, run_service, ds, judge_model=judge_model,
|
||||
regression_gate=body.regression_gate, min_pass_rate=body.min_pass_rate,
|
||||
end_user=end_user, run_context=rc)
|
||||
|
||||
|
||||
@router.post("/{dataset_id}/run/stream")
|
||||
async def run_dataset_stream(project_id: str, dataset_id: str, body: RunIn | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
user: CurrentUser = Depends(require_role("editor")),
|
||||
rc: dict | None = Depends(run_context),
|
||||
run_service: RunService = Depends(get_run_service)):
|
||||
"""Same as /run, but streams SSE frames (start / item / done / error) so the console can
|
||||
render every case immediately and update each row live as its run finishes."""
|
||||
import json as _json
|
||||
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
body = body or RunIn()
|
||||
ds, judge_model, end_user = await _prepare_run(session, tenant_id=tenant_id, project_id=project_id,
|
||||
dataset_id=dataset_id, user=user)
|
||||
|
||||
async def gen():
|
||||
async for frame in EvalService.stream(
|
||||
run_service, ds, judge_model=judge_model, regression_gate=body.regression_gate,
|
||||
min_pass_rate=body.min_pass_rate, end_user=end_user, run_context=rc,
|
||||
):
|
||||
yield {"event": frame["event"], "data": _json.dumps(frame["data"], default=str)}
|
||||
|
||||
return EventSourceResponse(gen(), headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"})
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Live-agent inbox: list open handoffs and reply (resumes the run + pushes the answer)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import (
|
||||
CurrentUser,
|
||||
current_tenant_id,
|
||||
get_run_service,
|
||||
get_session,
|
||||
require_role,
|
||||
)
|
||||
from forge.models import HandoffRequest
|
||||
from forge.services.handoff import HandoffService
|
||||
from forge.services.runs import RunService
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/handoffs", tags=["handoff"])
|
||||
|
||||
|
||||
class ReplyIn(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
def _out(h: HandoffRequest) -> dict:
|
||||
return {
|
||||
"id": h.id, "run_id": h.run_id, "workflow_id": h.workflow_id, "channel_id": h.channel_id,
|
||||
"customer": h.customer, "customer_message": h.customer_message, "reason": h.reason,
|
||||
"status": h.status, "agent_id": h.agent_id,
|
||||
"at": h.created_at.isoformat() if h.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_handoffs(project_id: str, status: str = "open",
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("viewer"))):
|
||||
return [_out(h) for h in await HandoffService.list(session, tenant_id, project_id, status=status or None)]
|
||||
|
||||
|
||||
@router.post("/{handoff_id}/reply")
|
||||
async def reply_handoff(project_id: str, handoff_id: str, body: ReplyIn,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
user: CurrentUser = Depends(require_role("editor")),
|
||||
run_service: RunService = Depends(get_run_service)):
|
||||
h = (await session.execute(
|
||||
select(HandoffRequest).where(HandoffRequest.tenant_id == tenant_id, HandoffRequest.id == handoff_id)
|
||||
)).scalar_one_or_none()
|
||||
if h is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "handoff not found")
|
||||
if h.status != "open":
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"handoff is already {h.status}")
|
||||
return await HandoffService.reply(session, run_service, handoff=h, agent_id=user.id, message=body.message)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Health, readiness, version, and Prometheus metrics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
from sqlalchemy import text
|
||||
|
||||
import forge
|
||||
from forge.config import settings
|
||||
from forge.db import SessionLocal
|
||||
from forge.util.metrics import snapshot
|
||||
|
||||
router = APIRouter(tags=["meta"])
|
||||
|
||||
|
||||
def _v(pkg: str) -> str:
|
||||
try:
|
||||
return version(pkg)
|
||||
except PackageNotFoundError:
|
||||
return "not-installed"
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
@router.get("/livez")
|
||||
async def health() -> dict:
|
||||
"""Liveness: the process is up and serving. Cheap and dependency-free."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def _ping_redis() -> str:
|
||||
"""Ping Redis and confirm a worker heartbeat when arq is in use. Returns an 'ok'/'error'/…
|
||||
status string; only called when FORGE_REDIS_URL is set."""
|
||||
try:
|
||||
import redis
|
||||
|
||||
client = redis.Redis.from_url(settings.redis_url, decode_responses=True, socket_timeout=2)
|
||||
client.ping()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return f"error: {type(e).__name__}"
|
||||
return "ok"
|
||||
|
||||
|
||||
def _worker_status() -> str:
|
||||
"""Best-effort arq worker liveness via its health-check key in Redis (arq writes
|
||||
`arq:health-check`). 'missing' means Redis is up but no worker has checked in."""
|
||||
try:
|
||||
import redis
|
||||
|
||||
client = redis.Redis.from_url(settings.redis_url, decode_responses=True, socket_timeout=2)
|
||||
return "ok" if client.exists("arq:health-check") else "missing"
|
||||
except Exception as e: # noqa: BLE001
|
||||
return f"error: {type(e).__name__}"
|
||||
|
||||
|
||||
def _vector_status() -> str:
|
||||
try:
|
||||
import chromadb
|
||||
|
||||
chromadb.PersistentClient(path=settings.chroma_path).heartbeat()
|
||||
except Exception as e: # noqa: BLE001
|
||||
return f"error: {type(e).__name__}"
|
||||
return "ok"
|
||||
|
||||
|
||||
@router.get("/readyz")
|
||||
async def readyz(request: Request) -> JSONResponse:
|
||||
"""Readiness: can this instance actually serve traffic? Checks the DB, the durable
|
||||
checkpointer, and (when configured) Redis, the vector store, and a worker heartbeat. Returns
|
||||
503 when a GATING dependency is down so a load balancer / k8s probe routes around it
|
||||
(audit P-imp / finding k). The worker is reported but non-gating - the API can still serve
|
||||
while the worker tier is down (runs queue)."""
|
||||
checks: dict[str, str] = {}
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
await s.execute(text("SELECT 1"))
|
||||
checks["db"] = "ok"
|
||||
except Exception as e: # noqa: BLE001
|
||||
checks["db"] = f"error: {type(e).__name__}"
|
||||
checks["checkpointer"] = "ok" if getattr(request.app.state, "checkpointer", None) is not None else "missing"
|
||||
checks["vector_store"] = _vector_status() # reported; non-gating (API serves w/o knowledge)
|
||||
# Gating checks: everything that must be healthy for this instance to serve.
|
||||
gating = ["db", "checkpointer"]
|
||||
if settings.redis_url:
|
||||
checks["redis"] = _ping_redis()
|
||||
checks["worker"] = _worker_status() # reported, not gating
|
||||
gating.append("redis")
|
||||
ready = all(checks.get(k) == "ok" for k in gating)
|
||||
return JSONResponse({"ready": ready, "checks": checks}, status_code=200 if ready else 503)
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
async def metrics() -> PlainTextResponse:
|
||||
"""Prometheus text-format exposition of the in-process counters so operators can scrape
|
||||
them (per-worker; aggregate across replicas at the scraper). Complements the OTLP trace
|
||||
export. No new dependency - rendered directly from the counter snapshot.
|
||||
|
||||
Gated behind `settings.expose_metrics` (default off): the counters are an internal
|
||||
operational surface, so keep it disabled on public deployments and enable only where the
|
||||
scrape endpoint is reachable from a trusted network."""
|
||||
if not settings.expose_metrics:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "not found")
|
||||
lines: list[str] = []
|
||||
for name, value in sorted(snapshot().items()):
|
||||
metric = "forge_" + "".join(c if (c.isalnum() or c == "_") else "_" for c in name)
|
||||
lines.append(f"# TYPE {metric} counter")
|
||||
lines.append(f"{metric} {value}")
|
||||
return PlainTextResponse("\n".join(lines) + "\n", media_type="text/plain; version=0.0.4")
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
async def version_info() -> dict:
|
||||
# Dependency versions aid fingerprinting; gate behind the same operator-only switch as /metrics.
|
||||
if not settings.expose_metrics:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "not found")
|
||||
return {
|
||||
"name": "forge-api",
|
||||
"version": forge.__version__,
|
||||
"langchain": _v("langchain"),
|
||||
"langgraph": _v("langgraph"),
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Public inbound webhook endpoint for `webhook_in` triggers.
|
||||
|
||||
Authenticated by the unguessable per-trigger key in the path (+ optional HMAC
|
||||
signature). No JWT - external systems POST here. Rate-limited per trigger.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.deps import get_run_service
|
||||
from forge.secrets.store import SecretStore
|
||||
from forge.services.dispatch import dispatch_trigger
|
||||
from forge.services.runs import RunService
|
||||
from forge.services.triggers import TriggerService
|
||||
from forge.util.ratelimit import idempotency, rate_limiter
|
||||
from forge.util.tasks import spawn
|
||||
|
||||
log = logging.getLogger("forge.hooks")
|
||||
|
||||
router = APIRouter(prefix="/v1/hooks", tags=["hooks"])
|
||||
|
||||
# Delivery-id headers common providers send for at-least-once retries; used to dedupe so a
|
||||
# provider re-delivery doesn't double-run the workflow (duplicate side effects). A trigger may
|
||||
# name its own via config.dedupe_header, or opt into body-hash dedupe via config.dedupe_body.
|
||||
_DELIVERY_HEADERS = ("X-GitHub-Delivery", "X-Request-Id", "Idempotency-Key", "X-Delivery-Id", "X-Event-Id")
|
||||
|
||||
# Default replay window (seconds) for signature schemes that sign a timestamp (Stripe/Slack).
|
||||
# A signature older/newer than this is rejected so a captured request can't be replayed later.
|
||||
# Per-trigger override: config.signature_tolerance_seconds.
|
||||
_SIG_TOLERANCE_DEFAULT = 300
|
||||
|
||||
|
||||
def _dedupe_key(trigger, request: Request, raw: bytes) -> str | None:
|
||||
cfg = trigger.config or {}
|
||||
names = [cfg["dedupe_header"]] if cfg.get("dedupe_header") else list(_DELIVERY_HEADERS)
|
||||
for name in names:
|
||||
if not name:
|
||||
continue
|
||||
v = request.headers.get(name) or request.headers.get(name.lower())
|
||||
if v:
|
||||
return v.strip()
|
||||
if cfg.get("dedupe_body"):
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
return None
|
||||
|
||||
|
||||
def _hmac_hex(secret: str, payload: bytes) -> str:
|
||||
return hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _within_tolerance(ts_str: str | None, tolerance: int) -> bool:
|
||||
"""True if a signed unix timestamp is within `tolerance` seconds of now (replay guard).
|
||||
`tolerance <= 0` disables the check (accept any timestamp)."""
|
||||
if tolerance <= 0:
|
||||
return True
|
||||
try:
|
||||
ts = float(str(ts_str).strip())
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return abs(time.time() - ts) <= tolerance
|
||||
|
||||
|
||||
def _parse_kv_header(value: str) -> dict[str, str]:
|
||||
"""Parse a `k=v,k=v` header (Stripe-Signature) -> {k: last-v}. Multiple values for one key
|
||||
(e.g. several v1=) are handled by the caller via the raw header; this keeps the last."""
|
||||
out: dict[str, str] = {}
|
||||
for part in (value or "").split(","):
|
||||
if "=" in part:
|
||||
k, v = part.split("=", 1)
|
||||
out[k.strip()] = v.strip()
|
||||
return out
|
||||
|
||||
|
||||
def _verify_stripe(secret: str, request: Request, body: bytes, tolerance: int) -> bool:
|
||||
# Stripe-Signature: t=<unix>,v1=<hexmac>[,v1=<hexmac>]. Signed payload = "<t>.<body>".
|
||||
header = request.headers.get("Stripe-Signature") or request.headers.get("stripe-signature")
|
||||
if not header:
|
||||
return False
|
||||
fields = _parse_kv_header(header)
|
||||
t = fields.get("t")
|
||||
if not _within_tolerance(t, tolerance):
|
||||
return False
|
||||
expected = _hmac_hex(secret, f"{t}.".encode() + body)
|
||||
provided = [p.split("=", 1)[1].strip() for p in header.split(",") if p.strip().startswith("v1=")]
|
||||
return any(hmac.compare_digest(expected, p) for p in provided)
|
||||
|
||||
|
||||
def _verify_slack(secret: str, request: Request, body: bytes, tolerance: int) -> bool:
|
||||
# X-Slack-Signature: v0=<hexmac>; base string = "v0:<ts>:<body>"; ts from X-Slack-Request-Timestamp.
|
||||
ts = request.headers.get("X-Slack-Request-Timestamp") or request.headers.get("x-slack-request-timestamp")
|
||||
provided = request.headers.get("X-Slack-Signature") or request.headers.get("x-slack-signature")
|
||||
if not ts or not provided or not _within_tolerance(ts, tolerance):
|
||||
return False
|
||||
expected = "v0=" + _hmac_hex(secret, b"v0:" + ts.encode() + b":" + body)
|
||||
return hmac.compare_digest(expected, provided.strip())
|
||||
|
||||
|
||||
def _verify_hmac_sha256(secret: str, request: Request, body: bytes) -> bool:
|
||||
# Default / GitHub-style: HMAC-SHA256 over the raw body; header tolerates a "sha256=" prefix.
|
||||
signature = request.headers.get("x-forge-signature") or request.headers.get("X-Hub-Signature-256")
|
||||
if not signature:
|
||||
return False
|
||||
expected = _hmac_hex(secret, body)
|
||||
provided = signature.split("=", 1)[-1].strip() # tolerate "sha256=<hex>"
|
||||
return hmac.compare_digest(expected, provided)
|
||||
|
||||
|
||||
async def _verify_signature(trigger, request: Request, body: bytes) -> bool:
|
||||
"""Verify the inbound signature under the trigger's configured scheme (audit I).
|
||||
|
||||
Schemes (config.signature_scheme): "hmac_sha256" (default; GitHub-style, raw-body HMAC),
|
||||
"stripe" (t=,v1= over "<t>.<body>"), "slack" (v0: over "v0:<ts>:<body>"). Stripe/Slack also
|
||||
enforce a signed-timestamp tolerance window (config.signature_tolerance_seconds, default 300)
|
||||
to block replays."""
|
||||
cfg = trigger.config or {}
|
||||
if not cfg.get("require_signature"):
|
||||
return True
|
||||
if not cfg.get("secret_ref"):
|
||||
return False
|
||||
try:
|
||||
secret = str(await SecretStore().read_ref(
|
||||
tenant_id=trigger.tenant_id, project_id=trigger.project_id, ref=cfg["secret_ref"]
|
||||
))
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
scheme = (cfg.get("signature_scheme") or "hmac_sha256").lower()
|
||||
tolerance = int(cfg.get("signature_tolerance_seconds", _SIG_TOLERANCE_DEFAULT))
|
||||
try:
|
||||
if scheme == "stripe":
|
||||
return _verify_stripe(secret, request, body, tolerance)
|
||||
if scheme == "slack":
|
||||
return _verify_slack(secret, request, body, tolerance)
|
||||
if scheme in ("hmac_sha256", "hmac", "github", "default"):
|
||||
return _verify_hmac_sha256(secret, request, body)
|
||||
except Exception: # noqa: BLE001 - a malformed header must fail closed, not 500
|
||||
log.warning("signature verification error for trigger %s (scheme=%s)", trigger.id, scheme, exc_info=True)
|
||||
return False
|
||||
log.warning("unknown signature_scheme %r for trigger %s", scheme, trigger.id)
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/{key}")
|
||||
async def inbound_webhook(
|
||||
key: str,
|
||||
request: Request,
|
||||
wait: bool = False,
|
||||
run_service: RunService = Depends(get_run_service),
|
||||
):
|
||||
async with SessionLocal() as s:
|
||||
trigger = await TriggerService.by_key(s, key)
|
||||
if trigger is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "unknown or disabled webhook")
|
||||
|
||||
if not rate_limiter.allow(f"hook:{key}", rate=120, per=60):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "webhook rate limit exceeded")
|
||||
|
||||
raw = await request.body()
|
||||
if not await _verify_signature(trigger, request, raw):
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid signature")
|
||||
|
||||
# Idempotency: dedupe an at-least-once redelivery (same delivery id / configured key) so the
|
||||
# workflow doesn't run twice. Claim the key BEFORE dispatch so concurrent duplicates collapse.
|
||||
dedupe = _dedupe_key(trigger, request, raw)
|
||||
if dedupe:
|
||||
ik = f"hook:{trigger.id}:{dedupe}"
|
||||
if idempotency.get(ik) is not None:
|
||||
return {"accepted": True, "trigger": trigger.id, "deduplicated": True}
|
||||
idempotency.put(ik, {"accepted": True})
|
||||
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception: # noqa: BLE001 - non-JSON body
|
||||
payload = raw.decode("utf-8", "replace")
|
||||
|
||||
if wait:
|
||||
result = await dispatch_trigger(run_service, trigger, payload)
|
||||
return result
|
||||
# fire-and-forget: ack immediately, run in a TRACKED background task so failures are
|
||||
# logged (not silently swallowed) and a flood can't spawn unbounded coroutines (F4).
|
||||
accepted = spawn(dispatch_trigger(run_service, trigger, payload), name=f"webhook:{trigger.id}")
|
||||
if not accepted:
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, "server busy; retry shortly")
|
||||
return {"accepted": True, "trigger": trigger.id}
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Knowledge endpoints - sources (ingest text/url), Q&A pairs, and a search debugger."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
|
||||
from forge.schemas.dto import (
|
||||
KbSourceCreate,
|
||||
KbSourceOut,
|
||||
KnowledgeMapIn,
|
||||
KnowledgeSearchIn,
|
||||
QaPairCreate,
|
||||
QaPairOut,
|
||||
QaPairUpdate,
|
||||
RechunkBulkIn,
|
||||
RechunkIn,
|
||||
)
|
||||
from forge.services.knowledge import KnowledgeService, _strip_html
|
||||
from forge.services.versions import safe_snapshot
|
||||
from forge.util.tasks import spawn
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/knowledge", tags=["knowledge"])
|
||||
qa_router = APIRouter(prefix="/v1/projects/{project_id}/qa-pairs", tags=["knowledge"])
|
||||
|
||||
# Shown on a source when the background ingest task can't be scheduled (in-flight ceiling
|
||||
# reached). Better to fail the source visibly than leave it stuck "queued" forever.
|
||||
_QUEUE_FULL_MSG = "ingest queue is full; please retry in a moment"
|
||||
|
||||
# Upload DoS bounds (audit M3): cap the in-memory read and the PDF page count so a huge file or a
|
||||
# small decompression-bomb PDF can't exhaust the process. A global request-body-size limit is best
|
||||
# enforced at the reverse proxy / uvicorn in front of the app.
|
||||
_MAX_UPLOAD_BYTES = 25 * 1024 * 1024
|
||||
_MAX_PDF_PAGES = 500
|
||||
|
||||
# Extensions we KNOW are binary containers - decoding them as text yields mojibake garbage that
|
||||
# then gets embedded, so reject with a clear message instead (finding: binary files ingested as
|
||||
# latin-1). PDFs are handled separately (extractable text); these have no text-decode path here.
|
||||
_BINARY_EXTS = frozenset({
|
||||
".docx", ".xlsx", ".pptx", ".doc", ".xls", ".ppt", ".odt", ".ods", ".odp", ".rtf",
|
||||
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp", ".ico", ".svgz",
|
||||
".zip", ".gz", ".tar", ".7z", ".rar", ".bz2", ".xz",
|
||||
".mp3", ".mp4", ".mov", ".avi", ".wav", ".flac", ".ogg", ".webm", ".mkv",
|
||||
".exe", ".dll", ".so", ".dylib", ".bin", ".class", ".jar", ".parquet", ".sqlite", ".db",
|
||||
})
|
||||
_SUPPORTED_HINT = "Supported: .txt, .md, .pdf, .csv, .json, .html and other UTF-8 text files."
|
||||
|
||||
|
||||
def _csv_to_text(text: str) -> str:
|
||||
"""CSV -> one header-qualified record per block ("col: value | col: value"), so each row is
|
||||
self-describing after chunking instead of an opaque comma soup. Falls back to raw text if it
|
||||
doesn't parse as tabular."""
|
||||
import csv
|
||||
import io
|
||||
|
||||
try:
|
||||
rows = list(csv.reader(io.StringIO(text)))
|
||||
except Exception: # noqa: BLE001 - not valid CSV -> ingest as-is
|
||||
return text
|
||||
if len(rows) < 2:
|
||||
return text
|
||||
header = [h.strip() for h in rows[0]]
|
||||
blocks: list[str] = []
|
||||
for row in rows[1:]:
|
||||
parts = [f"{(header[i] if i < len(header) else f'col{i + 1}')}: {v}"
|
||||
for i, v in enumerate(row) if str(v).strip()]
|
||||
if parts:
|
||||
blocks.append(" | ".join(parts))
|
||||
return "\n\n".join(blocks) if blocks else text
|
||||
|
||||
|
||||
def _json_record(item) -> str:
|
||||
import json
|
||||
|
||||
if isinstance(item, dict):
|
||||
return " | ".join(
|
||||
f"{k}: {v if isinstance(v, (str, int, float, bool)) else json.dumps(v, ensure_ascii=False)}"
|
||||
for k, v in item.items()
|
||||
)
|
||||
return json.dumps(item, ensure_ascii=False)
|
||||
|
||||
|
||||
def _json_to_text(text: str) -> str:
|
||||
"""JSON -> one record per block. A list becomes one block per element; a top-level object
|
||||
with a single list value expands that list (the common {"items": [...]} shape); otherwise the
|
||||
object is one header-qualified block. Falls back to raw text if it doesn't parse."""
|
||||
import json
|
||||
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except Exception: # noqa: BLE001 - not valid JSON -> ingest as-is
|
||||
return text
|
||||
if isinstance(data, dict):
|
||||
list_vals = [v for v in data.values() if isinstance(v, list)]
|
||||
if len(data) == 1 and list_vals:
|
||||
data = list_vals[0]
|
||||
if isinstance(data, list):
|
||||
blocks = [_json_record(x) for x in data]
|
||||
return "\n\n".join(b for b in blocks if b.strip()) or text
|
||||
return _json_record(data)
|
||||
|
||||
|
||||
def _decode_upload(name: str, raw: bytes) -> str:
|
||||
"""Turn uploaded bytes into ingestible text, or raise 422. Rejects known binary containers
|
||||
and null-byte binaries (finding: binaries decoded as latin-1 mojibake); strips HTML; parses
|
||||
CSV/JSON into per-record, header-qualified text (finding: tabular ingested opaquely)."""
|
||||
import os
|
||||
|
||||
ext = os.path.splitext(name.lower())[1]
|
||||
if ext in _BINARY_EXTS:
|
||||
raise HTTPException(422, f"'{ext}' files aren't a readable text format. {_SUPPORTED_HINT}")
|
||||
# Null bytes are the strongest signal of a binary we don't recognize by extension.
|
||||
if b"\x00" in raw:
|
||||
raise HTTPException(422, f"File looks binary, not text. {_SUPPORTED_HINT}")
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
# Retry as latin-1 ONLY for genuinely-textual non-UTF-8 files; if the result is mostly
|
||||
# unprintable it was binary after all -> reject rather than embed garbage.
|
||||
text = raw.decode("latin-1", errors="replace")
|
||||
printable = sum(c.isprintable() or c.isspace() for c in text)
|
||||
if not text or printable / len(text) < 0.85:
|
||||
raise HTTPException(422, f"File isn't a readable text format. {_SUPPORTED_HINT}") from None
|
||||
if ext in (".html", ".htm"):
|
||||
text = _strip_html(text)
|
||||
elif ext == ".csv":
|
||||
text = _csv_to_text(text)
|
||||
elif ext == ".json":
|
||||
text = _json_to_text(text)
|
||||
if not text.strip():
|
||||
raise HTTPException(422, "File is empty or not a readable text format.")
|
||||
return text
|
||||
|
||||
|
||||
async def _queue_ingest(session, tenant_id: str, src, *, reingest: bool = False, label: str = "ingest") -> None:
|
||||
"""Spawn the background ingest for `src`; if the task ceiling rejects it, mark the source
|
||||
errored (spawn returns False and closes the coro) so it doesn't sit 'queued' indefinitely."""
|
||||
if not spawn(KnowledgeService.run_ingest_bg(tenant_id, src.id, reingest=reingest), name=f"{label}:{src.id}"):
|
||||
await KnowledgeService.mark_error(session, src, _QUEUE_FULL_MSG)
|
||||
|
||||
|
||||
@router.get("/sources", response_model=list[KbSourceOut])
|
||||
async def list_sources(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
return await KnowledgeService.list_sources(session, tenant_id, project_id)
|
||||
|
||||
|
||||
@router.post("/sources", response_model=KbSourceOut, status_code=201)
|
||||
async def add_source(project_id: str, body: KbSourceCreate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(require_role("editor"))):
|
||||
src = await KnowledgeService.create_source(session, tenant_id, project_id, kind=body.kind, name=body.name, uri=body.uri, text=body.text, folder=body.folder, chunking_strategy=body.chunking_strategy, meta=body.meta)
|
||||
await safe_snapshot(session, "kb_source", src, author=user, label="added")
|
||||
# Ingest (fetch/chunk/embed) off the request: a real embedder takes seconds+ for a large
|
||||
# source, which would time out the HTTP call. The source returns as "queued"; the UI polls.
|
||||
await _queue_ingest(session, tenant_id, src)
|
||||
return src
|
||||
|
||||
|
||||
@router.post("/sources/upload", response_model=KbSourceOut, status_code=201)
|
||||
async def upload_source(
|
||||
project_id: str,
|
||||
file: UploadFile = File(...),
|
||||
folder: str = Form(""),
|
||||
chunking_strategy: str = Form(""),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
user: CurrentUser = Depends(require_role("editor")),
|
||||
):
|
||||
"""Upload a document file (.txt/.md/.pdf and other text formats) into the knowledge base."""
|
||||
# Bound the in-memory read so an oversized upload can't exhaust memory (audit M3): read one byte
|
||||
# past the cap to detect oversize without materializing the whole body.
|
||||
raw = await file.read(_MAX_UPLOAD_BYTES + 1)
|
||||
if len(raw) > _MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, f"file too large (max {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB)")
|
||||
name = file.filename or "upload"
|
||||
lower = name.lower()
|
||||
if lower.endswith(".pdf"):
|
||||
import io
|
||||
|
||||
from pypdf import PdfReader
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(raw))
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(422, f"Could not read PDF: {e}") from e
|
||||
# Cap page count: a small but deeply-compressed / many-page PDF can make extract_text
|
||||
# explode CPU/memory (a decompression bomb) (audit M3).
|
||||
if len(reader.pages) > _MAX_PDF_PAGES:
|
||||
raise HTTPException(413, f"PDF has too many pages (max {_MAX_PDF_PAGES})")
|
||||
try:
|
||||
text = "\n\n".join((page.extract_text() or "") for page in reader.pages).strip()
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(422, f"Could not read PDF: {e}") from e
|
||||
if not text:
|
||||
raise HTTPException(422, "PDF contains no extractable text (scanned image PDFs need OCR).")
|
||||
else:
|
||||
# Strip HTML, parse CSV/JSON into per-record text, and reject binaries with a clear 422.
|
||||
text = _decode_upload(name, raw)
|
||||
src = await KnowledgeService.create_source(session, tenant_id, project_id, kind="file", name=name, text=text, folder=folder, chunking_strategy=(chunking_strategy or None))
|
||||
await safe_snapshot(session, "kb_source", src, author=user, label="added")
|
||||
# The file bytes are fully read above; only the chunk+embed runs in the background so a
|
||||
# large upload doesn't block (and time out) the request. Returns "queued"; the UI polls.
|
||||
await _queue_ingest(session, tenant_id, src)
|
||||
return src
|
||||
|
||||
|
||||
@router.get("/folders", response_model=list[str])
|
||||
async def list_folders(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
return await KnowledgeService.list_folders(session, tenant_id, project_id)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def embedding_health(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
"""Embedding-dimension health: flags sources embedded with a different model than the
|
||||
project's current embedder (which would silently return no search results)."""
|
||||
return await KnowledgeService.embedding_health(session, tenant_id, project_id)
|
||||
|
||||
|
||||
@router.post("/sources/{source_id}/reingest")
|
||||
async def reingest_source(project_id: str, source_id: str, body: RechunkIn | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
"""Re-fetch + re-embed a source (re-crawl a site, or re-embed under the current model).
|
||||
An optional body overrides the chunking (strategy / size / overlap) before re-ingest."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from forge.models import KbSource
|
||||
src = (await session.execute(select(KbSource).where(KbSource.tenant_id == tenant_id, KbSource.id == source_id))).scalar_one_or_none()
|
||||
if src is None:
|
||||
raise HTTPException(status_code=404, detail="source not found")
|
||||
if body and (body.chunking_strategy or body.chunk_size is not None or body.chunk_overlap is not None):
|
||||
KnowledgeService._apply_chunk_overrides(
|
||||
src, chunking_strategy=body.chunking_strategy,
|
||||
chunk_size=body.chunk_size, chunk_overlap=body.chunk_overlap,
|
||||
)
|
||||
# Mark pending + persist overrides, then re-embed off the request (see add_source). Re-embed /
|
||||
# re-chunk is NOT snapshotted: it's frequent config churn, not an add/remove worth logging.
|
||||
src.status = "queued"
|
||||
await session.commit()
|
||||
await _queue_ingest(session, tenant_id, src, reingest=True, label="reingest")
|
||||
return {"id": src.id, "status": src.status, "chunks": src.chunks}
|
||||
|
||||
|
||||
@router.post("/sources/rechunk")
|
||||
async def rechunk_sources(project_id: str, body: RechunkBulkIn, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
"""Re-chunk a multi-selected set of sources with one shared set of overrides
|
||||
(strategy / size / overlap), then re-embed each (in the background). Tenant/project-scoped."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from forge.models import KbSource
|
||||
if not body.source_ids:
|
||||
return []
|
||||
rows = (await session.execute(
|
||||
select(KbSource).where(
|
||||
KbSource.tenant_id == tenant_id, KbSource.project_id == project_id,
|
||||
KbSource.id.in_(body.source_ids),
|
||||
)
|
||||
)).scalars().all()
|
||||
for src in rows:
|
||||
KnowledgeService._apply_chunk_overrides(
|
||||
src, chunking_strategy=body.chunking_strategy,
|
||||
chunk_size=body.chunk_size, chunk_overlap=body.chunk_overlap,
|
||||
)
|
||||
src.status = "queued"
|
||||
await session.commit() # persist overrides + queued status for the whole batch at once
|
||||
for src in rows:
|
||||
await _queue_ingest(session, tenant_id, src, reingest=True, label="rechunk")
|
||||
return [{"id": src.id, "status": src.status, "chunks": src.chunks} for src in rows]
|
||||
|
||||
|
||||
@router.patch("/sources/{source_id}", response_model=KbSourceOut)
|
||||
async def update_source(project_id: str, source_id: str, body: dict, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id), _: CurrentUser = Depends(require_role("editor"))):
|
||||
"""Move a source between folders (the only mutable field; content requires re-ingest).
|
||||
Not snapshotted: a folder move is minor bookkeeping, not an add/remove worth logging."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from forge.models import KbSource
|
||||
src = (await session.execute(select(KbSource).where(KbSource.tenant_id == tenant_id, KbSource.id == source_id))).scalar_one_or_none()
|
||||
if src is None:
|
||||
raise HTTPException(404, "Source not found")
|
||||
if "folder" in body:
|
||||
src.folder = str(body["folder"] or "")
|
||||
await session.commit()
|
||||
await session.refresh(src)
|
||||
return src
|
||||
|
||||
|
||||
@router.delete("/sources/{source_id}", status_code=204)
|
||||
async def delete_source(project_id: str, source_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(require_role("editor"))):
|
||||
from sqlalchemy import select
|
||||
|
||||
from forge.models import KbSource
|
||||
src = (await session.execute(select(KbSource).where(KbSource.tenant_id == tenant_id, KbSource.id == source_id))).scalar_one_or_none()
|
||||
if src is None:
|
||||
raise HTTPException(404, "Source not found")
|
||||
# Log the removal (name captured in the snapshot) before the row is gone.
|
||||
await safe_snapshot(session, "kb_source", src, author=user, label="removed")
|
||||
await KnowledgeService.delete_source(session, src)
|
||||
|
||||
|
||||
@router.post("/search")
|
||||
async def search(project_id: str, body: KnowledgeSearchIn, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
hits = await KnowledgeService.search(session, tenant_id, project_id, body.query, top_k=body.top_k, folders=body.folders, hybrid=body.hybrid, rerank=body.rerank)
|
||||
return [{"text": h.text, "score": round(h.score, 4), "source_id": h.metadata.get("source_id")} for h in hits]
|
||||
|
||||
|
||||
@router.post("/dedupe")
|
||||
async def dedupe_chunks(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id), _: CurrentUser = Depends(require_role("editor"))):
|
||||
"""Remove exact-duplicate chunks (identical text) project-wide, keeping one copy of each.
|
||||
Returns {removed, groups, sources_affected, remaining}."""
|
||||
return await KnowledgeService.dedupe_chunks(session, tenant_id, project_id)
|
||||
|
||||
|
||||
@router.post("/map")
|
||||
async def chunk_map(project_id: str, body: KnowledgeMapIn, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
"""Chunk-map visualizer: 2-D (PCA) projection of the project's chunk vectors, colored by
|
||||
source, with an optional query overlay showing what retrieval returns. Read-only."""
|
||||
return await KnowledgeService.chunk_map(
|
||||
session, tenant_id, project_id, query=body.query, folders=body.folders,
|
||||
source_ids=body.source_ids, limit=body.limit, hybrid=body.hybrid,
|
||||
rerank=body.rerank, top_k=body.top_k,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/chunk")
|
||||
async def chunk_detail(project_id: str, chunk_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
"""Full text (+ light metadata) of a single chunk, fetched on demand when a dot is selected
|
||||
in the chunk map (the map payload carries only a short preview). Scoped to the tenant/project.
|
||||
404 when the id isn't in this project's current-embedder collection."""
|
||||
detail = await KnowledgeService.chunk_detail(session, tenant_id, project_id, chunk_id)
|
||||
if detail is None:
|
||||
raise HTTPException(404, "Chunk not found.")
|
||||
return detail
|
||||
|
||||
|
||||
@qa_router.get("", response_model=list[QaPairOut])
|
||||
async def list_qa(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
return await KnowledgeService.list_qa(session, tenant_id, project_id)
|
||||
|
||||
|
||||
@qa_router.get("/kinds", response_model=list[str])
|
||||
async def list_qa_kinds(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
return await KnowledgeService.list_qa_kinds(session, tenant_id, project_id)
|
||||
|
||||
|
||||
@qa_router.post("", response_model=QaPairOut, status_code=201)
|
||||
async def add_qa(project_id: str, body: QaPairCreate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(require_role("editor"))):
|
||||
qa = await KnowledgeService.create_qa(session, tenant_id, project_id, question=body.question, answer=body.answer, kind=body.kind, tags=body.tags)
|
||||
await safe_snapshot(session, "qa_pair", qa, author=user, label="added")
|
||||
return qa
|
||||
|
||||
|
||||
@qa_router.patch("/{qa_id}", response_model=QaPairOut)
|
||||
async def update_qa(project_id: str, qa_id: str, body: QaPairUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
user: CurrentUser = Depends(require_role("editor"))):
|
||||
from sqlalchemy import select
|
||||
|
||||
from forge.models import QaPair
|
||||
|
||||
qa = (await session.execute(select(QaPair).where(
|
||||
QaPair.tenant_id == tenant_id, QaPair.project_id == project_id, QaPair.id == qa_id,
|
||||
))).scalar_one_or_none()
|
||||
if qa is None:
|
||||
raise HTTPException(404, "Q&A pair not found")
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
if changes.get("question") is not None and not changes["question"].strip():
|
||||
raise HTTPException(422, "Question cannot be empty")
|
||||
qa = await KnowledgeService.update_qa(session, qa, **changes)
|
||||
await safe_snapshot(session, "qa_pair", qa, author=user, label="changed")
|
||||
return qa
|
||||
|
||||
|
||||
@qa_router.delete("/{qa_id}", status_code=204)
|
||||
async def delete_qa(project_id: str, qa_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(require_role("editor"))):
|
||||
from sqlalchemy import select
|
||||
|
||||
from forge.models import QaPair
|
||||
qa = (await session.execute(select(QaPair).where(
|
||||
QaPair.tenant_id == tenant_id, QaPair.project_id == project_id, QaPair.id == qa_id,
|
||||
))).scalar_one_or_none()
|
||||
if qa is None:
|
||||
raise HTTPException(404, "Q&A pair not found")
|
||||
await safe_snapshot(session, "qa_pair", qa, author=user, label="removed")
|
||||
await KnowledgeService.delete_qa(session, qa)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""MCP client CRUD - register external MCP servers a project's tools can consume."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
|
||||
from forge.models import McpClient
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/mcp-clients", tags=["mcp-clients"])
|
||||
|
||||
|
||||
class McpClientIn(BaseModel):
|
||||
name: str
|
||||
transport: str = "streamable_http" # streamable_http | sse | stdio
|
||||
url: str | None = None
|
||||
command: str | None = None
|
||||
args: dict = {}
|
||||
headers_ref: str | None = None
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class McpClientPatch(BaseModel):
|
||||
name: str | None = None
|
||||
enabled: bool | None = None
|
||||
disabled_tools: list | None = None # remote tool names toggled off
|
||||
url: str | None = None
|
||||
headers_ref: str | None = None
|
||||
|
||||
|
||||
def _out(m: McpClient) -> dict:
|
||||
return {"id": m.id, "name": m.name, "transport": m.transport, "url": m.url,
|
||||
"command": m.command, "args": m.args, "headers_ref": m.headers_ref,
|
||||
"enabled": m.enabled, "disabled_tools": m.disabled_tools or []}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_clients(project_id: str, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id)):
|
||||
rows = (await session.execute(
|
||||
select(McpClient).where(McpClient.tenant_id == tenant_id, McpClient.project_id == project_id)
|
||||
)).scalars()
|
||||
return [_out(m) for m in rows]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_client(project_id: str, body: McpClientIn, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
m = McpClient(tenant_id=tenant_id, project_id=project_id, name=body.name, transport=body.transport,
|
||||
url=body.url, command=body.command, args=body.args, headers_ref=body.headers_ref, enabled=body.enabled)
|
||||
session.add(m)
|
||||
await session.commit()
|
||||
await session.refresh(m)
|
||||
return _out(m)
|
||||
|
||||
|
||||
@router.patch("/{client_id}")
|
||||
async def update_client(project_id: str, client_id: str, body: McpClientPatch, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
m = (await session.execute(
|
||||
select(McpClient).where(McpClient.tenant_id == tenant_id, McpClient.id == client_id)
|
||||
)).scalar_one_or_none()
|
||||
if m is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "mcp client not found")
|
||||
if body.name is not None:
|
||||
m.name = body.name
|
||||
if body.enabled is not None:
|
||||
m.enabled = body.enabled
|
||||
if body.disabled_tools is not None:
|
||||
m.disabled_tools = body.disabled_tools
|
||||
if body.url is not None:
|
||||
m.url = body.url
|
||||
if body.headers_ref is not None:
|
||||
m.headers_ref = body.headers_ref
|
||||
await session.commit()
|
||||
await session.refresh(m)
|
||||
# Drop the cached connection so running agents pick up the new config (audit F12).
|
||||
from forge.tools.mcp import invalidate_client
|
||||
invalidate_client(client_id)
|
||||
return _out(m)
|
||||
|
||||
|
||||
@router.get("/{client_id}/tools")
|
||||
async def list_remote_tools(project_id: str, client_id: str, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id)):
|
||||
"""Connect to the server and list the tools it exposes - drives the 'pick which to add' UI."""
|
||||
from forge.tools.mcp import McpUnavailable, discover_tools
|
||||
|
||||
row = (await session.execute(
|
||||
select(McpClient).where(McpClient.tenant_id == tenant_id, McpClient.id == client_id)
|
||||
)).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "mcp client not found")
|
||||
try:
|
||||
tools = await discover_tools(row, tenant_id, project_id)
|
||||
except McpUnavailable as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
except Exception as e: # noqa: BLE001 - surface connect/auth errors to the UI, don't 500
|
||||
return {"ok": False, "error": f"Could not connect: {e}"}
|
||||
return {"ok": True, "tools": tools}
|
||||
|
||||
|
||||
@router.delete("/{client_id}")
|
||||
async def delete_client(project_id: str, client_id: str, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
m = (await session.execute(
|
||||
select(McpClient).where(McpClient.tenant_id == tenant_id, McpClient.id == client_id)
|
||||
)).scalar_one_or_none()
|
||||
if m is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "mcp client not found")
|
||||
await session.delete(m)
|
||||
await session.commit()
|
||||
from forge.tools.mcp import invalidate_client
|
||||
invalidate_client(client_id)
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,317 @@
|
||||
"""OAuth 2.1 authorization + resource server for Forge's MCP endpoint (MCP authorization spec).
|
||||
|
||||
Lets ANY standard MCP client (Claude Desktop, Cursor, VS Code) authenticate a user over
|
||||
`POST /v1/mcp/{project_id}` without a pre-shared key: the client discovers this server, registers
|
||||
dynamically (RFC 7591), runs an authorization-code + PKCE flow, and presents the resulting
|
||||
audience-bound access token as `Authorization: Bearer …`. The MCP router validates the token and
|
||||
acts AS that user (forge.routers.mcp_server._oauth_end_user).
|
||||
|
||||
Standards implemented: OAuth 2.1 (authorization code + PKCE S256, public clients), Protected
|
||||
Resource Metadata (RFC 9728), Authorization Server Metadata (RFC 8414), Dynamic Client
|
||||
Registration (RFC 7591), and Resource Indicators (RFC 8707 - the token `aud` is the project's
|
||||
canonical MCP URL, so tokens can't be replayed at a different resource).
|
||||
|
||||
GATED: everything here 404s unless `settings.mcp_oauth_enabled` is on, so an operator opts in after
|
||||
review. KNOWN REVIEW ITEMS (documented, intentionally conservative): the consent screen is a
|
||||
minimal server-rendered login form; MFA-enabled accounts are refused here (no MFA bypass) and must
|
||||
use a personal access token; there is no per-client consent memory. Harden these before relying on
|
||||
it in production.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import html as _html
|
||||
import secrets as _secrets
|
||||
import urllib.parse
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from forge.config import settings
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.models import OAuthClient
|
||||
from forge.security import (
|
||||
TokenError,
|
||||
create_mcp_access_token,
|
||||
create_mcp_authorization_code,
|
||||
create_mcp_refresh_token,
|
||||
decode_token,
|
||||
revoke,
|
||||
)
|
||||
from forge.services.auth import AuthError, AuthService
|
||||
from forge.util.ratelimit import rate_limiter
|
||||
|
||||
router = APIRouter(tags=["mcp-oauth"])
|
||||
|
||||
|
||||
def _enabled() -> None:
|
||||
if not settings.mcp_oauth_enabled:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "MCP OAuth is not enabled")
|
||||
|
||||
|
||||
def _base() -> str:
|
||||
return settings.public_base_url.rstrip("/")
|
||||
|
||||
|
||||
def _valid_redirect(uri: str) -> bool:
|
||||
"""Only https, or a loopback address for native clients (open-redirect / spec hygiene)."""
|
||||
try:
|
||||
p = urllib.parse.urlparse(uri)
|
||||
except ValueError:
|
||||
return False
|
||||
if p.scheme == "https":
|
||||
return True
|
||||
return p.scheme in ("http",) and p.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
|
||||
|
||||
def _pkce_ok(verifier: str, challenge: str) -> bool:
|
||||
digest = hashlib.sha256((verifier or "").encode()).digest()
|
||||
computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
||||
return hmac.compare_digest(computed, challenge or "")
|
||||
|
||||
|
||||
async def _load_client(client_id: str | None) -> OAuthClient | None:
|
||||
if not client_id:
|
||||
return None
|
||||
async with SessionLocal() as s:
|
||||
return (await s.execute(select(OAuthClient).where(OAuthClient.client_id == client_id))).scalar_one_or_none()
|
||||
|
||||
|
||||
def _oauth_error(error: str, description: str, status_code: int = 400) -> JSONResponse:
|
||||
return JSONResponse({"error": error, "error_description": description}, status_code=status_code)
|
||||
|
||||
|
||||
# --- Discovery (RFC 8414 / RFC 9728) --------------------------------------------------------
|
||||
@router.get("/.well-known/oauth-authorization-server")
|
||||
async def authorization_server_metadata():
|
||||
_enabled()
|
||||
b = _base()
|
||||
return {
|
||||
"issuer": b,
|
||||
"authorization_endpoint": f"{b}/v1/oauth/authorize",
|
||||
"token_endpoint": f"{b}/v1/oauth/token",
|
||||
"registration_endpoint": f"{b}/v1/oauth/register",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": ["none"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/.well-known/oauth-protected-resource/v1/mcp/{project_id}")
|
||||
async def protected_resource_metadata(project_id: str):
|
||||
_enabled()
|
||||
b = _base()
|
||||
return {"resource": f"{b}/v1/mcp/{project_id}", "authorization_servers": [b]}
|
||||
|
||||
|
||||
# --- Dynamic client registration (RFC 7591) -------------------------------------------------
|
||||
@router.post("/v1/oauth/register", status_code=201)
|
||||
async def register_client(body: dict):
|
||||
_enabled()
|
||||
redirect_uris = body.get("redirect_uris") or []
|
||||
if not isinstance(redirect_uris, list) or not redirect_uris:
|
||||
return _oauth_error("invalid_client_metadata", "redirect_uris is required")
|
||||
if not all(isinstance(u, str) and _valid_redirect(u) for u in redirect_uris):
|
||||
return _oauth_error("invalid_redirect_uri", "redirect_uris must be https or loopback http")
|
||||
client_id = "mcp_" + _secrets.token_urlsafe(24)
|
||||
name = str(body.get("client_name"))[:200] if body.get("client_name") else None
|
||||
async with SessionLocal() as s:
|
||||
s.add(OAuthClient(client_id=client_id, client_name=name, redirect_uris=redirect_uris))
|
||||
await s.commit()
|
||||
return {
|
||||
"client_id": client_id,
|
||||
"client_name": name,
|
||||
"redirect_uris": redirect_uris,
|
||||
"token_endpoint_auth_method": "none",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
}
|
||||
|
||||
|
||||
# --- Authorization endpoint (code + PKCE) ---------------------------------------------------
|
||||
# Branded consent page - mirrors the console's login card (Forge wordmark, accent-orange
|
||||
# button, card on a tinted background) so the OAuth login reads as Forge, while staying a
|
||||
# self-contained server-rendered page (the authorization server can't depend on the SPA).
|
||||
# Uses %%…%% sentinels (not str.format/f-string) so the CSS braces need no escaping. Every
|
||||
# injected value is HTML-escaped; HIDDEN/ERR are substituted before NAME so a sentinel-looking
|
||||
# client name can't be re-substituted (str.replace is single-pass, non-recursive).
|
||||
_CONSENT_TEMPLATE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Authorize %%NAME%% · Forge</title>
|
||||
<style>
|
||||
:root{--bg:#F6F7F9;--card:#FFFFFF;--line:#E2E6EC;--fg:#11161C;--fg2:#7A848F;--accent:#E8541F;--accent-dim:#B8420F;--err:#D23A34;}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#0A0C0F;--card:#0F1318;--line:#2A323D;--fg:#EEF2F6;--fg2:#7A848F;--accent:#FF6A3D;--accent-dim:#C24E2B;--err:#F2615B;}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--bg);color:var(--fg);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;}
|
||||
.card{width:380px;max-width:calc(100vw - 32px);background:var(--card);border:1px solid var(--line);border-radius:14px;padding:28px;box-shadow:0 12px 40px rgba(17,22,28,.12);}
|
||||
.brand{font-size:22px;font-weight:700;letter-spacing:-.01em;margin:0 0 4px;}
|
||||
.sub{color:var(--fg2);font-size:14px;line-height:1.5;margin:0 0 20px;}
|
||||
.sub b{color:var(--fg);font-weight:600;}
|
||||
label{display:block;margin-bottom:12px;}
|
||||
.lbl{display:block;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--fg2);margin-bottom:6px;}
|
||||
.lbl .hint{text-transform:none;letter-spacing:0;font-weight:400;}
|
||||
input{width:100%;height:38px;padding:0 12px;font-size:14px;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:8px;outline:none;}
|
||||
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(232,84,31,.15);}
|
||||
button{width:100%;height:40px;margin-top:6px;border:0;border-radius:8px;background:var(--accent);color:#fff;font-size:14px;font-weight:600;cursor:pointer;}
|
||||
button:hover{background:var(--accent-dim);}
|
||||
.err{color:var(--err);font-size:13px;margin-bottom:12px;}
|
||||
.fieldhint{color:var(--fg2);font-size:12px;line-height:1.45;margin:-6px 0 14px;}
|
||||
</style></head>
|
||||
<body>
|
||||
<form class="card" method="post" action="/v1/oauth/authorize">
|
||||
<div class="brand">Forge</div>
|
||||
<div class="sub"><b>%%NAME%%</b> wants to access your Forge tools over MCP, acting as you.</div>
|
||||
%%ERR%%
|
||||
%%HIDDEN%%
|
||||
<label><span class="lbl">Email</span><input name="email" type="email" required autocomplete="username"></label>
|
||||
<label><span class="lbl">Password</span><input name="password" type="password" required autocomplete="current-password"></label>
|
||||
<label><span class="lbl">Workspace id <span class="hint">(optional)</span></span><input name="workspace_id"></label>
|
||||
<div class="fieldhint">Leave blank unless the same email is registered in multiple workspaces. It's your workspace ID (Settings > General), not the project ID.</div>
|
||||
<button type="submit">Authorize</button>
|
||||
</form>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def _consent_html(fields: dict, client: OAuthClient, error: str | None = None) -> str:
|
||||
def h(v: object) -> str:
|
||||
return _html.escape(str(v or ""))
|
||||
|
||||
hidden = "".join(
|
||||
f'<input type="hidden" name="{h(k)}" value="{h(v)}">'
|
||||
for k, v in fields.items()
|
||||
)
|
||||
err = f'<div class="err">{h(error)}</div>' if error else ""
|
||||
name = h(client.client_name or client.client_id)
|
||||
return (
|
||||
_CONSENT_TEMPLATE
|
||||
.replace("%%HIDDEN%%", hidden)
|
||||
.replace("%%ERR%%", err)
|
||||
.replace("%%NAME%%", name)
|
||||
)
|
||||
|
||||
|
||||
def _authorize_fields(q: dict) -> dict:
|
||||
return {
|
||||
"client_id": q.get("client_id", ""),
|
||||
"redirect_uri": q.get("redirect_uri", ""),
|
||||
"code_challenge": q.get("code_challenge", ""),
|
||||
"state": q.get("state", ""),
|
||||
"resource": q.get("resource", ""),
|
||||
"scope": q.get("scope", ""),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/v1/oauth/authorize", response_class=HTMLResponse)
|
||||
async def authorize_form(request: Request):
|
||||
_enabled()
|
||||
q = dict(request.query_params)
|
||||
if q.get("response_type") != "code":
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "response_type must be 'code'")
|
||||
if q.get("code_challenge_method") != "S256" or not q.get("code_challenge"):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "PKCE with code_challenge_method=S256 is required")
|
||||
client = await _load_client(q.get("client_id"))
|
||||
if client is None or q.get("redirect_uri") not in (client.redirect_uris or []):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "unknown client_id or unregistered redirect_uri")
|
||||
return HTMLResponse(_consent_html(_authorize_fields(q), client))
|
||||
|
||||
|
||||
@router.post("/v1/oauth/authorize")
|
||||
async def authorize_submit(
|
||||
request: Request,
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
workspace_id: str = Form(""),
|
||||
client_id: str = Form(...),
|
||||
redirect_uri: str = Form(...),
|
||||
code_challenge: str = Form(...),
|
||||
state: str = Form(""),
|
||||
resource: str = Form(""),
|
||||
scope: str = Form(""),
|
||||
):
|
||||
_enabled()
|
||||
fields = {"client_id": client_id, "redirect_uri": redirect_uri, "code_challenge": code_challenge,
|
||||
"state": state, "resource": resource, "scope": scope}
|
||||
client = await _load_client(client_id)
|
||||
if client is None or redirect_uri not in (client.redirect_uris or []):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "unknown client_id or unregistered redirect_uri")
|
||||
# Throttle the credential form per client IP (brute-force guard on this login surface).
|
||||
ip = request.client.host if request.client else "?"
|
||||
if not rate_limiter.allow(f"oauth_authorize:{ip}", rate=20, per=60):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "too many attempts, slow down")
|
||||
|
||||
async with SessionLocal() as s:
|
||||
try:
|
||||
user = await AuthService.authenticate(s, email=email, password=password, tenant_id=workspace_id or None)
|
||||
except AuthError as e:
|
||||
return HTMLResponse(_consent_html(fields, client, error=str(e)), status_code=401)
|
||||
# No MFA bypass: an account with TOTP enabled must not authorize via this minimal form.
|
||||
if await AuthService.totp_status(s, user.id):
|
||||
return HTMLResponse(
|
||||
_consent_html(fields, client, error="This account uses MFA; use a personal access token for MCP instead."),
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
code = create_mcp_authorization_code(claims={
|
||||
"sub": user.id, "tid": user.tenant_id, "role": user.role,
|
||||
"cid": client_id, "ru": redirect_uri, "cc": code_challenge, "res": resource or "",
|
||||
})
|
||||
sep = "&" if "?" in redirect_uri else "?"
|
||||
url = f"{redirect_uri}{sep}code={urllib.parse.quote(code)}"
|
||||
if state:
|
||||
url += f"&state={urllib.parse.quote(state)}"
|
||||
return RedirectResponse(url, status_code=status.HTTP_302_FOUND)
|
||||
|
||||
|
||||
# --- Token endpoint -------------------------------------------------------------------------
|
||||
@router.post("/v1/oauth/token")
|
||||
async def token(
|
||||
grant_type: str = Form(...),
|
||||
code: str = Form(""),
|
||||
redirect_uri: str = Form(""),
|
||||
client_id: str = Form(""),
|
||||
code_verifier: str = Form(""),
|
||||
refresh_token: str = Form(""),
|
||||
):
|
||||
_enabled()
|
||||
if grant_type == "authorization_code":
|
||||
try:
|
||||
claims = decode_token(code, expected_type="mcp_auth_code")
|
||||
except TokenError:
|
||||
return _oauth_error("invalid_grant", "invalid or expired authorization code")
|
||||
if claims.get("cid") != client_id or claims.get("ru") != redirect_uri:
|
||||
return _oauth_error("invalid_grant", "client_id / redirect_uri mismatch")
|
||||
if not _pkce_ok(code_verifier, claims.get("cc", "")):
|
||||
return _oauth_error("invalid_grant", "PKCE verification failed")
|
||||
# Single-use: burn the code's jti so a replayed authorization code is rejected (decode_token
|
||||
# checks the revocation denylist). `exp` lets the entry self-prune.
|
||||
revoke(claims.get("jti"), exp=claims.get("exp"))
|
||||
resource = claims.get("res") or ""
|
||||
access = create_mcp_access_token(claims={
|
||||
"sub": claims["sub"], "tid": claims["tid"], "role": claims.get("role"),
|
||||
"res": resource, "cid": client_id,
|
||||
})
|
||||
refresh = create_mcp_refresh_token(claims={
|
||||
"sub": claims["sub"], "tid": claims["tid"], "role": claims.get("role"),
|
||||
"cid": client_id, "res": resource,
|
||||
})
|
||||
return {"access_token": access, "token_type": "Bearer", "expires_in": 3600, "refresh_token": refresh, "scope": ""}
|
||||
|
||||
if grant_type == "refresh_token":
|
||||
try:
|
||||
claims = decode_token(refresh_token, expected_type="mcp_refresh")
|
||||
except TokenError:
|
||||
return _oauth_error("invalid_grant", "invalid refresh_token")
|
||||
if client_id and claims.get("cid") != client_id:
|
||||
return _oauth_error("invalid_grant", "client mismatch")
|
||||
resource = claims.get("res") or ""
|
||||
access = create_mcp_access_token(claims={
|
||||
"sub": claims["sub"], "tid": claims["tid"], "role": claims.get("role"),
|
||||
"res": resource, "cid": claims.get("cid"),
|
||||
})
|
||||
return {"access_token": access, "token_type": "Bearer", "expires_in": 3600, "scope": ""}
|
||||
|
||||
return _oauth_error("unsupported_grant_type", f"unsupported grant_type {grant_type!r}")
|
||||
@@ -0,0 +1,512 @@
|
||||
"""Expose a project's tools as an MCP server, over two transports that share one core.
|
||||
|
||||
- **Streamable HTTP** (MCP spec 2025-03-26+): the transport native clients (Claude Desktop,
|
||||
Cursor, VS Code) speak directly, so they connect WITHOUT an `mcp-remote` proxy bridge. A POST
|
||||
is answered with `application/json` or, when the client's `Accept` allows it, a
|
||||
`text/event-stream` (SSE) reply; GET/DELETE are handled by the SDK transport. Backed by the
|
||||
official `mcp` SDK's StreamableHTTPServerTransport in STATELESS mode — a fresh transport per
|
||||
request, no server-side session state — which is exactly Forge's model: every request is
|
||||
authenticated on its own and the tool surface is resolved per project + per acting identity.
|
||||
- **Legacy JSON-RPC** (request/response over HTTP POST): the original hand-rolled path, kept for
|
||||
simple HTTP clients and internal callers that POST plain JSON without the streamable headers.
|
||||
|
||||
The transport is chosen per request: a POST whose `Accept` includes `text/event-stream`, or any
|
||||
GET/DELETE, is served over Streamable HTTP; a plain-JSON POST uses the legacy path. Both resolve the
|
||||
SAME surface (`_resolve`) and run the SAME dispatch (`_dispatch`), so behavior and tracing are
|
||||
identical whichever transport a client uses. Auth is a per-project API key stored in
|
||||
`project.config.mcp_api_key` (required when set; open in the no-auth dev default).
|
||||
|
||||
Tool sets ("toolsets", GitHub-MCP style): a project's tools can be published as named groups.
|
||||
- Base endpoint /v1/mcp/{project_id} exposes the project's published surface
|
||||
(project.config.mcp_published_toolsets: a list of set slugs, or "all"/"default"; unset =>
|
||||
every enabled tool, the prior behavior).
|
||||
- Per-set endpoint /v1/mcp/{project_id}/toolset/{slug} exposes ONLY that set's tools, so
|
||||
a client can add one MCP server per toolset. The optional name allow-list
|
||||
(project.config.mcp_exposed_tools) still applies as a further filter.
|
||||
|
||||
Project tools (base endpoint only, each gated by a project.config flag, published ALONGSIDE the
|
||||
toolset tools and independent of the toolset allow-list): the whole configured workflow as one
|
||||
tool (mcp_expose_workflow -> run_workflow), knowledge-base document search (mcp_expose_knowledge
|
||||
-> search_knowledge_base) and curated Q&A lookup (mcp_expose_faq -> lookup_faq). The two knowledge
|
||||
tools reuse the SAME builder the agent nodes use, so their behavior and tracing match exactly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from starlette.responses import Response
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from forge.config import settings
|
||||
from forge.db.base import SessionLocal
|
||||
from forge.deps import run_context as parse_run_context
|
||||
from forge.models import Project, User
|
||||
from forge.security import TokenError, decode_token
|
||||
from forge.services.apikeys import ApiKeyService, looks_like_pat
|
||||
from forge.services.runtime import build_compile_context
|
||||
from forge.services.tool_sets import ToolSetService
|
||||
from forge.util.ratelimit import rate_limiter
|
||||
|
||||
# Streamable-HTTP transport comes from the optional `mcp` extra. Import it lazily-at-module-load
|
||||
# behind a guard so this router still registers (legacy JSON-RPC keeps working) when the extra is
|
||||
# not installed; the streamable branch then returns a clear 501. anyio is a core dependency.
|
||||
try:
|
||||
import anyio
|
||||
from mcp import types as mcp_types
|
||||
from mcp.server.lowlevel import Server as LowLevelMCPServer
|
||||
from mcp.server.streamable_http import StreamableHTTPServerTransport
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
|
||||
# Forge already enforces trusted-hosts + https public URLs at the app layer in prod, so the
|
||||
# transport's own DNS-rebinding Host/Origin check (default ON, empty allow-lists => blocks all)
|
||||
# is redundant here and would reject every request. Disable it and rely on the app-layer guard.
|
||||
_MCP_SECURITY = TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
||||
_STREAMABLE_OK = True
|
||||
except Exception: # pragma: no cover - exercised only when the `mcp` extra is absent
|
||||
_STREAMABLE_OK = False
|
||||
|
||||
log = logging.getLogger("forge.routers.mcp_server")
|
||||
|
||||
router = APIRouter(prefix="/v1/mcp", tags=["mcp-server"])
|
||||
|
||||
|
||||
def _workflow_tool_name(cfg: dict) -> str | None:
|
||||
"""The MCP tool name that runs the project's configured workflow, if exposure is enabled
|
||||
(project.config.mcp_expose_workflow). Lets an external MCP client invoke a whole Forge
|
||||
workflow as a single tool, not just the project's individual tools."""
|
||||
if not cfg.get("mcp_expose_workflow"):
|
||||
return None
|
||||
return str(cfg.get("mcp_workflow_tool_name") or "run_workflow")
|
||||
|
||||
|
||||
def _capability_tools(cfg: dict, ctx, toolset_slug: str | None) -> list:
|
||||
"""Project-level knowledge tools exposed over MCP, gated by project.config flags (mirrors
|
||||
`_workflow_tool_name`): `mcp_expose_knowledge` -> `search_knowledge_base` (RAG over the
|
||||
project's documents) and `mcp_expose_faq` -> `lookup_faq` (curated Q&A). Reuses the SAME
|
||||
builder the agent nodes use (`build_knowledge_capability_tools`), so behavior + tracing match.
|
||||
|
||||
Base endpoint only: like the workflow tool these are a whole-project surface, not part of any
|
||||
one toolset, so a per-set (toolset) endpoint never carries them.
|
||||
"""
|
||||
if toolset_slug:
|
||||
return []
|
||||
kn: dict = {}
|
||||
if cfg.get("mcp_expose_knowledge"):
|
||||
kn["rag"] = {"enabled": True}
|
||||
if cfg.get("mcp_expose_faq"):
|
||||
kn["qa"] = {"enabled": True}
|
||||
if not kn:
|
||||
return []
|
||||
from forge.tools.builtin import build_knowledge_capability_tools
|
||||
|
||||
return build_knowledge_capability_tools(kn, ctx)
|
||||
|
||||
|
||||
def _tool_input_schema(tool) -> dict:
|
||||
"""The JSON Schema for a StructuredTool's arguments, degrading to an open object on error."""
|
||||
try:
|
||||
return tool.args_schema.model_json_schema() if tool.args_schema else {"type": "object"}
|
||||
except Exception: # noqa: BLE001
|
||||
return {"type": "object"}
|
||||
|
||||
|
||||
def _exposed_names(ctx, sets: list, toolset_slug: str | None, excluded_ids: set[str]) -> set[str]:
|
||||
"""The flat set of tool NAMES to expose over MCP.
|
||||
|
||||
MCP has no "toolset" primitive - `tools/list` is a flat array - so tool sets are purely a
|
||||
Forge-side grouping we flatten here. The surface is the enabled tools of EXPOSED sets, MINUS
|
||||
any individually excluded tools: everything is published by default and the operator unticks
|
||||
what they don't want (project.config.mcp_excluded_tools = tool ids). `ctx.tool_specs` holds only
|
||||
ENABLED tools, so disabled tools drop out automatically. No loose/direct tools: a tool that
|
||||
isn't in an exposed set is not published. `toolset_slug` scopes to that one set if exposed.
|
||||
"""
|
||||
if toolset_slug:
|
||||
chosen = [x for x in sets if x.slug == toolset_slug and x.exposed]
|
||||
else:
|
||||
chosen = [x for x in sets if x.exposed]
|
||||
names: set[str] = set()
|
||||
for st in chosen:
|
||||
for tid in ctx.toolset_members.get(st.id, []):
|
||||
if tid in excluded_ids:
|
||||
continue
|
||||
spec = ctx.tool_specs.get(tid)
|
||||
if spec is not None:
|
||||
names.add(spec["tool"].name)
|
||||
return names
|
||||
|
||||
|
||||
def _bearer(request: Request) -> str | None:
|
||||
auth = request.headers.get("authorization") or ""
|
||||
parts = auth.split(None, 1)
|
||||
return parts[1].strip() if len(parts) == 2 and parts[0].lower() == "bearer" else None
|
||||
|
||||
|
||||
async def _load_project(project_id: str) -> Project:
|
||||
async with SessionLocal() as s:
|
||||
proj = (await s.execute(select(Project).where(Project.id == project_id))).scalar_one_or_none()
|
||||
if proj is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "project not found")
|
||||
return proj
|
||||
|
||||
|
||||
def _session_end_user(token: str | None, proj: Project) -> dict | None:
|
||||
"""The verified end_user carried by a Forge session token scoped to THIS project, or None.
|
||||
Lets an MCP client authenticate as a specific end user (create_session_token) instead of the
|
||||
shared project key, so entitlement gating and {{ctx.*}} injection act on their behalf."""
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
claims = decode_token(token, expected_type="session")
|
||||
except TokenError:
|
||||
return None
|
||||
if claims.get("pid") != proj.id or claims.get("tid") != proj.tenant_id:
|
||||
return None
|
||||
eu = claims.get("end_user")
|
||||
return eu if isinstance(eu, dict) else None
|
||||
|
||||
|
||||
async def _pat_end_user(token: str | None, proj: Project) -> dict | None:
|
||||
"""The end_user for a per-user Personal Access Token (forge_pat_) presented to a project's MCP
|
||||
server, or None. Lets an individual authenticate any MCP client with a pasteable token; the
|
||||
acting identity is the token's owning user, scoped to the token's tenant (+ project if set)."""
|
||||
if not token or not looks_like_pat(token):
|
||||
return None
|
||||
async with SessionLocal() as s:
|
||||
key = await ApiKeyService.resolve_personal(s, token)
|
||||
if key is None or key.tenant_id != proj.tenant_id:
|
||||
return None
|
||||
if key.project_id and key.project_id != proj.id:
|
||||
return None
|
||||
user = (await s.execute(select(User).where(User.id == key.user_id))).scalar_one_or_none()
|
||||
if user is None or user.status != "active":
|
||||
return None
|
||||
return {"id": user.id, "email": user.email, "display_name": user.email, "roles": [user.role]}
|
||||
|
||||
|
||||
async def _oauth_end_user(token: str | None, proj: Project) -> dict | None:
|
||||
"""The end_user for a Forge-issued OAuth 2.1 MCP access token (see forge.routers.mcp_oauth),
|
||||
or None. Validates the token's audience is THIS project's canonical MCP URL (RFC 8707) so a
|
||||
token minted for another resource can't be replayed here. Only active when MCP OAuth is on."""
|
||||
if not settings.mcp_oauth_enabled or not token:
|
||||
return None
|
||||
try:
|
||||
claims = decode_token(token, expected_type="mcp_access")
|
||||
except TokenError:
|
||||
return None
|
||||
canonical = f"{settings.public_base_url.rstrip('/')}/v1/mcp/{proj.id}"
|
||||
res = claims.get("res") or "" # resource binding (RFC 8707); a custom claim so the shared
|
||||
if res != canonical and not res.startswith(canonical): # JWT decoder doesn't reject on `aud`
|
||||
return None
|
||||
async with SessionLocal() as s:
|
||||
user = (await s.execute(select(User).where(User.id == claims.get("sub")))).scalar_one_or_none()
|
||||
if user is None or user.status != "active" or user.tenant_id != proj.tenant_id:
|
||||
return None
|
||||
return {"id": user.id, "email": user.email, "display_name": user.email, "roles": [user.role]}
|
||||
|
||||
|
||||
def _rpc(rid, result=None, error=None):
|
||||
out = {"jsonrpc": "2.0", "id": rid}
|
||||
if error is not None:
|
||||
out["error"] = error
|
||||
else:
|
||||
out["result"] = result
|
||||
return out
|
||||
|
||||
|
||||
async def _authorize(request: Request, proj: Project, cfg: dict) -> dict | None:
|
||||
"""Authorize the caller and resolve the acting end user (identity), raising 401 on failure.
|
||||
Shared by both transports. Two accepted credential kinds:
|
||||
- the shared per-project mcp_api_key -> authorized, NO per-user identity (server-to-server);
|
||||
- a per-user credential (project-scoped session token, forge_pat_ PAT, or OAuth 2.1 access
|
||||
token) -> authorized AS that user, so entitlement gating + {{ctx.*}} act on their behalf.
|
||||
Portable "use anywhere" identity: any MCP client just sends Authorization: Bearer <token>."""
|
||||
bearer = _bearer(request)
|
||||
api_key = cfg.get("mcp_api_key")
|
||||
if api_key and hmac.compare_digest(bearer or "", api_key):
|
||||
return None # shared-key mode (constant-time compare; matches deps.py)
|
||||
end_user = (
|
||||
_session_end_user(bearer, proj)
|
||||
or await _pat_end_user(bearer, proj)
|
||||
or await _oauth_end_user(bearer, proj)
|
||||
)
|
||||
if end_user is None and (api_key or settings.auth_required or settings.mcp_oauth_enabled):
|
||||
detail = "invalid MCP credential" if api_key else "authentication required to use this MCP server"
|
||||
headers = None
|
||||
if settings.mcp_oauth_enabled:
|
||||
# RFC 9728: point the client at this resource's metadata so it can start OAuth.
|
||||
prm = f"{settings.public_base_url.rstrip('/')}/.well-known/oauth-protected-resource/v1/mcp/{proj.id}"
|
||||
headers = {"WWW-Authenticate": f'Bearer resource_metadata="{prm}"'}
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail, headers=headers)
|
||||
return end_user
|
||||
|
||||
|
||||
# --- tool surface + dispatch (shared by both transports) -------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Surface:
|
||||
"""The resolved MCP surface for one request: the compile context, the flat allow-list of
|
||||
exposed tool names, the optional workflow tool name, the project-level knowledge tools (by
|
||||
name), and the toolset scope. Built once per request by `_resolve`."""
|
||||
ctx: Any
|
||||
allow: set[str]
|
||||
wf_tool: str | None
|
||||
cap_tools: dict
|
||||
toolset_slug: str | None
|
||||
|
||||
|
||||
async def _resolve(proj: Project, cfg: dict, toolset_slug: str | None, end_user: dict | None, rc) -> _Surface:
|
||||
"""Resolve the project's exposed tool surface for the acting identity + run context."""
|
||||
async with SessionLocal() as s:
|
||||
ctx = await build_compile_context(
|
||||
s, tenant_id=proj.tenant_id, project_id=proj.id, end_user=end_user, run_context=rc,
|
||||
)
|
||||
sets = await ToolSetService.list(s, proj.tenant_id, proj.id)
|
||||
excluded_ids = {str(x) for x in (cfg.get("mcp_excluded_tools") or [])}
|
||||
allow = _exposed_names(ctx, sets, toolset_slug, excluded_ids)
|
||||
cap_tools = {t.name: t for t in _capability_tools(cfg, ctx, toolset_slug)}
|
||||
return _Surface(ctx=ctx, allow=allow, wf_tool=_workflow_tool_name(cfg), cap_tools=cap_tools, toolset_slug=toolset_slug)
|
||||
|
||||
|
||||
def _list_items(surface: _Surface) -> list[dict]:
|
||||
"""The `tools/list` array: enabled tools of exposed sets, plus the base-endpoint-only
|
||||
workflow + knowledge tools. Same shape for both transports."""
|
||||
items: list[dict] = []
|
||||
for spec in surface.ctx.tool_specs.values():
|
||||
tool = spec["tool"]
|
||||
if tool.name not in surface.allow:
|
||||
continue # only enabled tools of exposed tool sets are published
|
||||
items.append({"name": tool.name, "description": tool.description or "", "inputSchema": _tool_input_schema(tool)})
|
||||
# The whole-workflow-as-one-tool and the knowledge tools are a project-level surface: base
|
||||
# endpoint only, never a per-set (toolset) endpoint.
|
||||
if surface.wf_tool and not surface.toolset_slug:
|
||||
items.append({
|
||||
"name": surface.wf_tool,
|
||||
"description": "Run this project's configured workflow with a text message and return its reply.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"message": {"type": "string", "description": "The user message / input to run the workflow with."}},
|
||||
"required": ["message"],
|
||||
},
|
||||
})
|
||||
for t in surface.cap_tools.values():
|
||||
items.append({"name": t.name, "description": t.description or "", "inputSchema": _tool_input_schema(t)})
|
||||
return items
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CallResult:
|
||||
"""Outcome of a tools/call. `unknown` marks a tool that is not exposed / not found: the legacy
|
||||
transport renders it as a JSON-RPC -32602 error, while Streamable HTTP (per the MCP spec, which
|
||||
reserves protocol errors for malformed requests) renders it as a tool result with isError."""
|
||||
text: str
|
||||
is_error: bool
|
||||
unknown: bool = False
|
||||
|
||||
|
||||
async def _dispatch(request: Request, proj: Project, cfg: dict, surface: _Surface, rc, end_user: dict | None, name: str, args: dict) -> _CallResult:
|
||||
"""Run one tools/call against the resolved surface. Shared by both transports."""
|
||||
# Workflow-as-MCP: run the project's configured workflow to completion and return its answer.
|
||||
if surface.wf_tool and not surface.toolset_slug and name == surface.wf_tool:
|
||||
from forge.routers.project_run import _configured_workflow
|
||||
from forge.services.runs import RunService
|
||||
|
||||
run_service = RunService(
|
||||
checkpointer=getattr(request.app.state, "checkpointer", None),
|
||||
store=getattr(request.app.state, "store", None),
|
||||
)
|
||||
message = args.get("message") or args.get("input") or ""
|
||||
try:
|
||||
async with SessionLocal() as s:
|
||||
wf = await _configured_workflow(s, proj.tenant_id, proj.id)
|
||||
run = await run_service.create_run(
|
||||
s, tenant_id=proj.tenant_id, project_id=proj.id, workflow_id=wf.id,
|
||||
input={"messages": [{"role": "user", "content": str(message)}]}, source="mcp",
|
||||
end_user=end_user,
|
||||
)
|
||||
result = await run_service.run_to_completion(
|
||||
run_id=run.id, tenant_id=proj.tenant_id, project_id=proj.id, run_context=rc,
|
||||
)
|
||||
return _CallResult(text=result.get("answer") or "", is_error=result.get("status") == "error")
|
||||
except Exception: # noqa: BLE001
|
||||
# Don't leak internal error/stack detail to the external MCP client; log it server-side.
|
||||
log.exception("mcp workflow run failed (project=%s)", proj.id)
|
||||
return _CallResult(text="error: workflow run failed", is_error=True)
|
||||
# Project-level knowledge tools bypass the toolset allow-list (they're a project surface,
|
||||
# not a toolset member), so dispatch them before the allow check.
|
||||
cap = surface.cap_tools.get(name)
|
||||
if cap is not None:
|
||||
try:
|
||||
result = await cap.ainvoke(args)
|
||||
return _CallResult(text=str(result), is_error=False)
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("mcp knowledge tool failed (project=%s, tool=%s)", proj.id, name)
|
||||
return _CallResult(text="error: tool invocation failed", is_error=True)
|
||||
if name not in surface.allow:
|
||||
return _CallResult(text=f"tool {name!r} is not exposed", is_error=True, unknown=True)
|
||||
tool = next((sp["tool"] for sp in surface.ctx.tool_specs.values() if sp["tool"].name == name), None)
|
||||
if tool is None:
|
||||
return _CallResult(text=f"unknown tool {name!r}", is_error=True, unknown=True)
|
||||
try:
|
||||
result = await tool.ainvoke(args)
|
||||
return _CallResult(text=str(result), is_error=False)
|
||||
except Exception: # noqa: BLE001
|
||||
# Don't leak internal error/stack detail to the external MCP client; log it server-side.
|
||||
log.exception("mcp tool invocation failed (project=%s, tool=%s)", proj.id, name)
|
||||
return _CallResult(text="error: tool invocation failed", is_error=True)
|
||||
|
||||
|
||||
# --- Streamable HTTP transport (official mcp SDK, stateless) ---------------------------------
|
||||
|
||||
|
||||
class _ASGIResponse(Response):
|
||||
"""A FastAPI-returnable Response that hands the raw ASGI channels to an MCP transport. FastAPI
|
||||
passes any `Response` instance straight through to Starlette, which then calls it as an ASGI
|
||||
app - letting the transport own the reply (single JSON body or a streamed text/event-stream)."""
|
||||
|
||||
def __init__(self, handler):
|
||||
self._handler = handler # async (scope, receive, send) -> None
|
||||
self.background = None # FastAPI reads this on any returned Response before dispatch
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
await self._handler(scope, receive, send)
|
||||
|
||||
|
||||
def _forge_lowlevel_server(request: Request, proj: Project, cfg: dict, *, toolset_slug: str | None, end_user: dict | None, rc):
|
||||
"""A per-request MCP `Server` whose tools/list + tools/call resolve Forge's surface lazily
|
||||
(so `initialize` stays cheap and the surface reflects the caller's identity). One server per
|
||||
request keeps the acting identity + run context baked in - no shared/global server state."""
|
||||
name = f"forge-{proj.slug or proj.id}" + (f"-{toolset_slug}" if toolset_slug else "")
|
||||
server = LowLevelMCPServer(name=name, version="1.0.0")
|
||||
|
||||
@server.list_tools()
|
||||
async def _lt() -> list:
|
||||
surface = await _resolve(proj, cfg, toolset_slug, end_user, rc)
|
||||
return [
|
||||
mcp_types.Tool(name=i["name"], description=i["description"], inputSchema=i["inputSchema"])
|
||||
for i in _list_items(surface)
|
||||
]
|
||||
|
||||
@server.call_tool(validate_input=False)
|
||||
async def _ct(tool_name: str, arguments: dict):
|
||||
surface = await _resolve(proj, cfg, toolset_slug, end_user, rc)
|
||||
res = await _dispatch(request, proj, cfg, surface, rc, end_user, tool_name, arguments)
|
||||
return mcp_types.CallToolResult(
|
||||
content=[mcp_types.TextContent(type="text", text=res.text)],
|
||||
isError=res.is_error or res.unknown,
|
||||
)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
async def _run_streamable(request: Request, proj: Project, server) -> _ASGIResponse:
|
||||
"""Serve one request over a fresh stateless Streamable-HTTP transport. Mirrors the SDK's own
|
||||
stateless path: connect the transport, run the MCP server over its in-memory streams in a task,
|
||||
hand the HTTP request to the transport (which replies JSON or SSE), then tear the transport down."""
|
||||
transport = StreamableHTTPServerTransport(
|
||||
mcp_session_id=None, # stateless: no session, no cross-request state
|
||||
is_json_response_enabled=False, # let the client's Accept decide JSON vs SSE
|
||||
event_store=None, # no resumability in stateless mode
|
||||
security_settings=_MCP_SECURITY,
|
||||
)
|
||||
|
||||
async def _drive(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
async def _serve(*, task_status=anyio.TASK_STATUS_IGNORED):
|
||||
async with transport.connect() as (read_stream, write_stream):
|
||||
task_status.started()
|
||||
try:
|
||||
await server.run(read_stream, write_stream, server.create_initialization_options(), stateless=True)
|
||||
except Exception: # noqa: BLE001 # pragma: no cover - server-side crash, logged only
|
||||
log.exception("mcp streamable session crashed (project=%s)", proj.id)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(_serve)
|
||||
await transport.handle_request(scope, receive, send)
|
||||
await transport.terminate()
|
||||
|
||||
return _ASGIResponse(_drive)
|
||||
|
||||
|
||||
# --- routes -----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.api_route("/{project_id}", methods=["GET", "POST", "DELETE"])
|
||||
async def mcp_rpc(project_id: str, request: Request):
|
||||
"""Base MCP endpoint: the project's published toolset surface (see module docstring)."""
|
||||
return await _handle(project_id, request, toolset_slug=None)
|
||||
|
||||
|
||||
@router.api_route("/{project_id}/toolset/{slug}", methods=["GET", "POST", "DELETE"])
|
||||
async def mcp_rpc_toolset(project_id: str, slug: str, request: Request):
|
||||
"""Scoped MCP endpoint: exposes only the tools in tool set `slug` (GitHub-style toolset)."""
|
||||
return await _handle(project_id, request, toolset_slug=slug)
|
||||
|
||||
|
||||
def _wants_stream(request: Request) -> bool:
|
||||
"""A request routes to Streamable HTTP if it's a GET/DELETE (transport-level methods) or a POST
|
||||
that accepts an SSE reply; a plain-JSON POST stays on the legacy request/response path."""
|
||||
if request.method in ("GET", "DELETE"):
|
||||
return True
|
||||
return "text/event-stream" in (request.headers.get("accept") or "").lower()
|
||||
|
||||
|
||||
async def _handle(project_id: str, request: Request, *, toolset_slug: str | None):
|
||||
proj = await _load_project(project_id)
|
||||
cfg = proj.config or {}
|
||||
end_user = await _authorize(request, proj, cfg)
|
||||
|
||||
# Rate-limit the exposed MCP surface per project (a single static key is shared by every
|
||||
# caller, so this is the real abuse ceiling). Uses the general API per-minute budget.
|
||||
if not rate_limiter.allow(f"mcp:{project_id}", rate=settings.api_rate_limit_per_minute, per=60):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "MCP rate limit exceeded")
|
||||
|
||||
# Ephemeral per-run context (X-Forge-Context): injected into tools as {{ctx.*}} for on-behalf-of
|
||||
# calls (e.g. the end user's downstream session); never persisted or prompted. Header-only, so
|
||||
# it is safe to read before the streamable transport consumes the request body.
|
||||
rc = parse_run_context(request)
|
||||
|
||||
# Streamable HTTP (native MCP clients: Claude Desktop / Cursor / VS Code).
|
||||
if _wants_stream(request):
|
||||
if not _STREAMABLE_OK:
|
||||
raise HTTPException(
|
||||
status.HTTP_501_NOT_IMPLEMENTED,
|
||||
"Streamable HTTP transport requires the 'mcp' extra (pip install -e '.[mcp]').",
|
||||
)
|
||||
server = _forge_lowlevel_server(request, proj, cfg, toolset_slug=toolset_slug, end_user=end_user, rc=rc)
|
||||
return await _run_streamable(request, proj, server)
|
||||
|
||||
# Legacy request/response JSON-RPC over HTTP POST.
|
||||
body = await request.json()
|
||||
rid = body.get("id")
|
||||
method = body.get("method")
|
||||
params = body.get("params") or {}
|
||||
|
||||
if method == "initialize":
|
||||
name = f"forge-{proj.slug or project_id}"
|
||||
if toolset_slug:
|
||||
name = f"{name}-{toolset_slug}"
|
||||
return _rpc(rid, {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": name, "version": "1.0.0"},
|
||||
})
|
||||
|
||||
if method in ("tools/list", "tools/call"):
|
||||
surface = await _resolve(proj, cfg, toolset_slug, end_user, rc)
|
||||
if method == "tools/list":
|
||||
return _rpc(rid, {"tools": _list_items(surface)})
|
||||
# tools/call
|
||||
name = params.get("name")
|
||||
args = params.get("arguments") or {}
|
||||
res = await _dispatch(request, proj, cfg, surface, rc, end_user, name, args)
|
||||
if res.unknown:
|
||||
return _rpc(rid, error={"code": -32602, "message": res.text})
|
||||
return _rpc(rid, {"content": [{"type": "text", "text": res.text}], "isError": res.is_error})
|
||||
|
||||
return _rpc(rid, error={"code": -32601, "message": f"method not found: {method}"})
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Personal access tokens for a project's MCP server.
|
||||
|
||||
A PAT is a per-user, pasteable bearer token (`forge_pat_…`) that authenticates an individual over
|
||||
`POST /v1/mcp/{project_id}` AS their end_user - the portable "use anywhere" identity for generic MCP
|
||||
clients (Claude Desktop, Cursor, VS Code). Bound to the current user and scoped to this project; it
|
||||
is deliberately NOT a general-API credential.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import CurrentUser, get_current_user, get_session
|
||||
from forge.models.entities import ApiKey
|
||||
from forge.schemas.dto import McpTokenCreate, McpTokenOut
|
||||
from forge.services.apikeys import ApiKeyService
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/mcp-tokens", tags=["mcp-tokens"])
|
||||
|
||||
|
||||
def _out(k: ApiKey, *, token: str | None = None) -> McpTokenOut:
|
||||
return McpTokenOut(
|
||||
id=k.id, name=k.name, prefix=k.prefix, project_id=k.project_id, status=k.status,
|
||||
created_at=k.created_at, last_used_at=k.last_used_at, expires_at=k.expires_at, token=token,
|
||||
)
|
||||
|
||||
|
||||
def _require_real_user(user: CurrentUser) -> None:
|
||||
# A PAT must bind a real end user; service / api-key / dev-fallback principals have no user id.
|
||||
if user.is_fallback or str(user.id).startswith(("apikey:", "service")):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "personal tokens require a logged-in user")
|
||||
|
||||
|
||||
@router.get("", response_model=list[McpTokenOut])
|
||||
async def list_mcp_tokens(project_id: str, session: AsyncSession = Depends(get_session),
|
||||
user: CurrentUser = Depends(get_current_user)):
|
||||
_require_real_user(user)
|
||||
rows = await ApiKeyService.list_personal(session, user.tenant_id, user.id)
|
||||
# Show this project's tokens plus any tenant-wide (unscoped) personal tokens.
|
||||
return [_out(k) for k in rows if k.project_id in (None, project_id)]
|
||||
|
||||
|
||||
@router.post("", response_model=McpTokenOut, status_code=201)
|
||||
async def create_mcp_token(project_id: str, body: McpTokenCreate, session: AsyncSession = Depends(get_session),
|
||||
user: CurrentUser = Depends(get_current_user)):
|
||||
_require_real_user(user)
|
||||
key, plaintext = await ApiKeyService.create_personal(
|
||||
session, tenant_id=user.tenant_id, user_id=user.id,
|
||||
name=body.name or "MCP token", project_id=project_id, ttl_days=body.ttl_days,
|
||||
)
|
||||
return _out(key, token=plaintext)
|
||||
|
||||
|
||||
@router.delete("/{token_id}", status_code=204)
|
||||
async def revoke_mcp_token(project_id: str, token_id: str, session: AsyncSession = Depends(get_session),
|
||||
user: CurrentUser = Depends(get_current_user)):
|
||||
_require_real_user(user)
|
||||
if not await ApiKeyService.revoke_personal(session, tenant_id=user.tenant_id, user_id=user.id, key_id=token_id):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "token not found")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Model catalog - drives every model picker in the console (chat, embedding, reranker).
|
||||
|
||||
Served from `forge.model_catalog`, the same lists the built-in pricing rates derive from, so a
|
||||
dropdown can only ever offer models the backend can actually run (and, for chat, price). This is
|
||||
the single source of truth: the frontend hardcodes no model lists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from forge.model_catalog import CHAT_MODELS, EMBEDDING_MODELS, RERANKER_MODELS
|
||||
|
||||
router = APIRouter(prefix="/v1/models", tags=["catalog"])
|
||||
|
||||
|
||||
class ChatModelOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
provider: str
|
||||
ctx: str
|
||||
tools: bool
|
||||
vision: bool
|
||||
|
||||
|
||||
class EmbeddingModelOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
provider: str
|
||||
dim: int
|
||||
billed: bool
|
||||
default: bool
|
||||
|
||||
|
||||
class RerankerModelOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
note: str
|
||||
default: bool
|
||||
|
||||
|
||||
class ModelCatalogOut(BaseModel):
|
||||
chat: list[ChatModelOut]
|
||||
embedding: list[EmbeddingModelOut]
|
||||
reranker: list[RerankerModelOut]
|
||||
|
||||
|
||||
@router.get("", response_model=ModelCatalogOut)
|
||||
async def list_models() -> ModelCatalogOut:
|
||||
return ModelCatalogOut(
|
||||
chat=[
|
||||
ChatModelOut(id=m.id, name=m.name, provider=m.provider, ctx=m.ctx, tools=m.tools, vision=m.vision)
|
||||
for m in CHAT_MODELS
|
||||
],
|
||||
embedding=[
|
||||
EmbeddingModelOut(id=m.id, name=m.name, provider=m.provider, dim=m.dim, billed=m.billed, default=m.default)
|
||||
for m in EMBEDDING_MODELS
|
||||
],
|
||||
reranker=[
|
||||
RerankerModelOut(id=m.id, name=m.name, note=m.note, default=m.default)
|
||||
for m in RERANKER_MODELS
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Node-type catalog - drives the canvas palette + validation (from the registry)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
import forge.nodes # noqa: F401 (ensure registration)
|
||||
from forge.engine.registry import all_specs
|
||||
from forge.schemas.dto import NodeTypeOut, PortOut
|
||||
|
||||
router = APIRouter(prefix="/v1/node-types", tags=["catalog"])
|
||||
|
||||
|
||||
def _ports(ports) -> list[PortOut]:
|
||||
return [
|
||||
PortOut(
|
||||
id=p.id, io_type=p.io_type, direction=p.direction,
|
||||
label=p.label, required=p.required, many=p.many,
|
||||
)
|
||||
for p in ports
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=list[NodeTypeOut])
|
||||
async def list_node_types() -> list[NodeTypeOut]:
|
||||
return [
|
||||
NodeTypeOut(
|
||||
type=s.type,
|
||||
category=s.category,
|
||||
label=s.label or s.type,
|
||||
description=s.description,
|
||||
schema_id=s.schema_id,
|
||||
allows_cycle=s.allows_cycle,
|
||||
input_ports=_ports(s.input_ports),
|
||||
output_ports=_ports(s.output_ports),
|
||||
)
|
||||
for s in all_specs()
|
||||
]
|
||||
@@ -0,0 +1,191 @@
|
||||
"""3-legged OAuth (authorization_code) connect flow for auth providers.
|
||||
|
||||
Flow: the console calls `/oauth/start` to get the provider's authorize URL (carrying a
|
||||
short-lived signed `state`); the user grants access and the provider redirects the
|
||||
browser to `/v1/oauth/callback`, which validates `state`, exchanges the code for tokens,
|
||||
and stores them as a secret. The AuthResolver then auto-refreshes on expiry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets as _secrets
|
||||
from html import escape
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.auth_providers.resolver import AuthResolver
|
||||
from forge.config import settings
|
||||
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
|
||||
from forge.models import AuthProvider
|
||||
from forge.secrets.store import SecretNotFound, SecretStore
|
||||
from forge.security import TokenError, create_state_token, decode_token
|
||||
from forge.util.http import shared_async_client
|
||||
from forge.util.ssrf import guarded_request
|
||||
|
||||
router = APIRouter(tags=["oauth"])
|
||||
|
||||
_PREFIX = "/v1/projects/{project_id}/auth-providers/{ap_id}/oauth"
|
||||
|
||||
|
||||
class OAuthStartIn(BaseModel):
|
||||
# Per-user connect (finding i): the end-user context whose token this connect establishes.
|
||||
# Only the dims named in the provider's `per_user_context_keys` are carried through the
|
||||
# (signed) state to the callback, which then stores the bundle under the SAME per-user
|
||||
# secret name that resolve/refresh read. Omit for a shared, single-account provider.
|
||||
context: dict | None = None
|
||||
|
||||
|
||||
def _redirect_uri(cfg: dict) -> str:
|
||||
return cfg.get("redirect_uri") or f"{settings.public_base_url.rstrip('/')}/v1/oauth/callback"
|
||||
|
||||
|
||||
async def _load(session, tenant_id: str, project_id: str, ap_id: str) -> AuthProvider:
|
||||
ap = (
|
||||
await session.execute(
|
||||
select(AuthProvider).where(
|
||||
AuthProvider.tenant_id == tenant_id, AuthProvider.project_id == project_id, AuthProvider.id == ap_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if ap is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "auth provider not found")
|
||||
if ap.kind != "oauth2_authorization_code":
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "provider is not an oauth2_authorization_code provider")
|
||||
return ap
|
||||
|
||||
|
||||
@router.post(_PREFIX + "/start")
|
||||
async def oauth_start(
|
||||
project_id: str, ap_id: str,
|
||||
body: OAuthStartIn | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor")),
|
||||
):
|
||||
ap = await _load(session, tenant_id, project_id, ap_id)
|
||||
cfg = ap.config or {}
|
||||
client_id = await SecretStore().read_ref(tenant_id=tenant_id, project_id=project_id, ref=cfg["client_id_ref"]) if cfg.get("client_id_ref") else None
|
||||
if not client_id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "client_id secret not configured")
|
||||
# PKCE (finding i): bind the authorization code to a per-request verifier so an intercepted
|
||||
# code can't be redeemed without it. The verifier rides in the SIGNED state (tamper-proof)
|
||||
# and is echoed back to us in the callback. NOTE: the signed state is readable by the
|
||||
# browser; for a PUBLIC client (no client_secret) store the verifier server-side instead.
|
||||
verifier = _secrets.token_urlsafe(64)
|
||||
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
|
||||
state_claims = {"tid": tenant_id, "pid": project_id, "ap": ap_id, "cv": verifier}
|
||||
# Per-user connect: carry ONLY the dims that key this provider's per-user bundle, so the
|
||||
# callback stores the token under the same per-user name resolve/refresh look up. Absent (or
|
||||
# a non-per-user provider) => the default single-account name, preserving prior behavior.
|
||||
per_user = cfg.get("per_user_context_keys") or []
|
||||
ctx = (body.context if body else None) or {}
|
||||
user_ctx = {k: ctx[k] for k in per_user if k in ctx}
|
||||
if user_ctx:
|
||||
state_claims["ctx"] = user_ctx
|
||||
state = create_state_token(state_claims)
|
||||
q = {
|
||||
"response_type": "code",
|
||||
"client_id": str(client_id),
|
||||
"redirect_uri": _redirect_uri(cfg),
|
||||
"state": state,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
if cfg.get("scope"):
|
||||
q["scope"] = cfg["scope"]
|
||||
return {"authorize_url": f"{cfg['authorize_url']}?{urlencode(q)}"}
|
||||
|
||||
|
||||
@router.get("/v1/oauth/callback", response_class=HTMLResponse)
|
||||
async def oauth_callback(
|
||||
code: str | None = None, state: str | None = None, error: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
# Every interpolated value below is provider/redirect-controlled, so HTML-escape it to
|
||||
# avoid reflected XSS on the API origin's callback page (audit S9).
|
||||
if error:
|
||||
return HTMLResponse(f"<h3>Authorization failed</h3><p>{escape(error)}</p>", status_code=400)
|
||||
if not code or not state:
|
||||
return HTMLResponse("<h3>Missing code/state</h3>", status_code=400)
|
||||
try:
|
||||
claims = decode_token(state, expected_type="oauth_state")
|
||||
except TokenError:
|
||||
# Don't reflect the decode error detail on this public callback page; the generic
|
||||
# message is enough for the user and the specifics aren't security-relevant to them.
|
||||
return HTMLResponse("<h3>Invalid or expired state</h3>", status_code=400)
|
||||
tenant_id, project_id, ap_id = claims["tid"], claims["pid"], claims["ap"]
|
||||
ap = await _load(session, tenant_id, project_id, ap_id)
|
||||
cfg = ap.config or {}
|
||||
|
||||
secrets = SecretStore()
|
||||
client_id = await secrets.read_ref(tenant_id=tenant_id, project_id=project_id, ref=cfg["client_id_ref"]) if cfg.get("client_id_ref") else None
|
||||
client_secret = await secrets.read_ref(tenant_id=tenant_id, project_id=project_id, ref=cfg["client_secret_ref"]) if cfg.get("client_secret_ref") else None
|
||||
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": _redirect_uri(cfg),
|
||||
"client_id": str(client_id) if client_id else None,
|
||||
"client_secret": str(client_secret) if client_secret else None,
|
||||
# PKCE proof matching the code_challenge sent at /start (finding i).
|
||||
"code_verifier": claims.get("cv"),
|
||||
}
|
||||
# Fetch the token through the SSRF guard (validates the host pre-connect AND re-validates
|
||||
# any redirect hop, with httpx's cross-origin credential stripping) rather than a raw POST
|
||||
# that would follow a redirect to an internal host (audit S8).
|
||||
r = await guarded_request(
|
||||
shared_async_client(), "POST", cfg["token_url"],
|
||||
data={k: v for k, v in data.items() if v is not None}, timeout=30, follow_redirects=True,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
return HTMLResponse(
|
||||
f"<h3>Token exchange failed ({escape(str(r.status_code))})</h3><pre>{escape(r.text[:500])}</pre>",
|
||||
status_code=400,
|
||||
)
|
||||
body = r.json()
|
||||
import time as _t
|
||||
|
||||
bundle = {
|
||||
"access_token": body.get("access_token"),
|
||||
"refresh_token": body.get("refresh_token"),
|
||||
"token_type": body.get("token_type", "Bearer"),
|
||||
"scope": body.get("scope", cfg.get("scope")),
|
||||
"expires_at": (_t.time() + int(body["expires_in"])) if body.get("expires_in") else None,
|
||||
}
|
||||
# Store under the SAME (possibly per-user) secret name resolve/refresh read. The end-user
|
||||
# context was carried through the signed state from /start; without it (shared account) this
|
||||
# is the default name. Previously the connect always wrote the default name, so a per-user
|
||||
# provider's token was stored where resolve never looked (finding i / item 5).
|
||||
bundle_name = AuthResolver.bundle_secret_name(ap_id, claims.get("ctx"), cfg.get("per_user_context_keys"))
|
||||
await AuthResolver()._store_bundle(tenant_id, project_id, ap_id, bundle, name=bundle_name)
|
||||
return HTMLResponse("<h3>✅ Connected</h3><p>You can close this window and return to Forge.</p>")
|
||||
|
||||
|
||||
@router.get(_PREFIX + "/status")
|
||||
async def oauth_status(
|
||||
project_id: str, ap_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("viewer")),
|
||||
):
|
||||
await _load(session, tenant_id, project_id, ap_id) # validates existence/kind
|
||||
try:
|
||||
bundle = await SecretStore().read_ref(
|
||||
tenant_id=tenant_id, project_id=project_id,
|
||||
ref=f"secret://proj/{AuthResolver.bundle_secret_name(ap_id)}",
|
||||
)
|
||||
except SecretNotFound:
|
||||
return {"connected": False}
|
||||
return {
|
||||
"connected": bool(isinstance(bundle, dict) and bundle.get("access_token")),
|
||||
"expires_at": bundle.get("expires_at") if isinstance(bundle, dict) else None,
|
||||
"scope": bundle.get("scope") if isinstance(bundle, dict) else None,
|
||||
"has_refresh": bool(isinstance(bundle, dict) and bundle.get("refresh_token")),
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Admin-editable model pricing (overlays the built-in defaults)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import CurrentUser, get_session, require_role
|
||||
from forge.models import ModelPrice
|
||||
from forge.tracing.pricing import load_overrides, merged_prices, set_override
|
||||
|
||||
router = APIRouter(prefix="/v1/pricing", tags=["pricing"])
|
||||
|
||||
|
||||
class PriceIn(BaseModel):
|
||||
input_per_1m: float
|
||||
output_per_1m: float
|
||||
|
||||
|
||||
async def load_pricing_overrides(session) -> None:
|
||||
rows = (await session.execute(select(ModelPrice))).scalars()
|
||||
load_overrides({r.model: (r.input_per_1m, r.output_per_1m) for r in rows})
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_pricing(_: CurrentUser = Depends(require_role("admin"))):
|
||||
return {m: {"input_per_1m": i, "output_per_1m": o} for m, (i, o) in sorted(merged_prices().items())}
|
||||
|
||||
|
||||
@router.put("/{model}")
|
||||
async def set_pricing(model: str, body: PriceIn, session: AsyncSession = Depends(get_session),
|
||||
_: CurrentUser = Depends(require_role("admin"))):
|
||||
existing = (await session.execute(select(ModelPrice).where(ModelPrice.model == model))).scalar_one_or_none()
|
||||
if existing:
|
||||
existing.input_per_1m = body.input_per_1m
|
||||
existing.output_per_1m = body.output_per_1m
|
||||
else:
|
||||
session.add(ModelPrice(model=model, input_per_1m=body.input_per_1m, output_per_1m=body.output_per_1m))
|
||||
await session.commit()
|
||||
set_override(model, body.input_per_1m, body.output_per_1m)
|
||||
return {"model": model, "input_per_1m": body.input_per_1m, "output_per_1m": body.output_per_1m}
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Single project-level run endpoint - the framework's simplest integration surface.
|
||||
|
||||
One authenticated POST runs the project's *configured* workflow (a saved project setting,
|
||||
`config.api_workflow_id`) and does everything the 3-endpoint run API does, in one call:
|
||||
|
||||
- new turn -> send `input` (+ `thread_id` to continue a conversation)
|
||||
- HITL -> send `resume` to answer an interrupt the workflow raised (workflow-driven, NOT
|
||||
a caller toggle)
|
||||
- `stream` -> the ONLY per-request knob: True streams SSE frames, False returns one JSON reply
|
||||
|
||||
Framework-generic: any project, any auth scheme. Per-user secrets (session/CSRF/bearer/etc.)
|
||||
travel out-of-band in the `X-Forge-Context` header -> `{{ctx.*}}` in tools, never in the body.
|
||||
Auth is the platform bearer: a service token for server-to-server callers, or a console JWT.
|
||||
|
||||
This is a thin wrapper over the existing run machinery (create_run + stream/resume/
|
||||
run_to_completion) - it adds no new execution path, only a single friendlier surface over the
|
||||
per-workflow run API at /v1/projects/{id}/workflows/{wid}/runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from forge.config import settings
|
||||
from forge.deps import (
|
||||
CurrentUser,
|
||||
get_current_user,
|
||||
get_run_service,
|
||||
get_session,
|
||||
run_context,
|
||||
)
|
||||
from forge.models import Project, Run, Thread, Workflow
|
||||
from forge.schemas.dto import ProjectRunIn
|
||||
from forge.services.auth import role_at_least
|
||||
from forge.services.runs import RunService
|
||||
from forge.util.ratelimit import idempotency, rate_limiter
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}", tags=["project-run"])
|
||||
|
||||
SSE_HEADERS = {"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"}
|
||||
|
||||
|
||||
async def _configured_workflow(session: AsyncSession, tenant_id: str, project_id: str) -> Workflow:
|
||||
"""The workflow this project's API runs: the saved `config.api_workflow_id`, else the
|
||||
active workflow, else the only one. Mirrors the embed's resolution (embed_public._workflow_id)
|
||||
so the two integration surfaces behave identically."""
|
||||
proj = (await session.execute(
|
||||
select(Project).where(Project.tenant_id == tenant_id, Project.id == project_id)
|
||||
)).scalar_one_or_none()
|
||||
if proj is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "project not found")
|
||||
rows = (await session.execute(
|
||||
select(Workflow).where(Workflow.project_id == project_id)
|
||||
)).scalars().all()
|
||||
wid = (proj.config or {}).get("api_workflow_id")
|
||||
if wid:
|
||||
wf = next((w for w in rows if w.id == wid), None)
|
||||
if wf is not None:
|
||||
return wf
|
||||
wf = next((w for w in rows if w.status == "active"), None) or (rows[0] if rows else None)
|
||||
if wf is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "no workflow configured for this project's API")
|
||||
return wf
|
||||
|
||||
|
||||
def _resolve_end_user(
|
||||
body: ProjectRunIn, user: CurrentUser, tenant_id: str, project_id: str
|
||||
) -> dict | None:
|
||||
"""Same identity rules as POST .../runs: a verified session token wins; else the body
|
||||
end_user, but a non-editor caller may NOT self-assert roles/entitlements (audit S4)."""
|
||||
if body.session_token:
|
||||
from forge.security import TokenError, decode_token
|
||||
|
||||
try:
|
||||
claims = decode_token(body.session_token, expected_type="session")
|
||||
except TokenError as e:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"invalid session token: {e}") from e
|
||||
if claims.get("tid") != tenant_id or claims.get("pid") != project_id:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "session token is not valid for this project")
|
||||
return claims.get("end_user") or None
|
||||
if body.end_user is not None:
|
||||
eu = body.end_user.model_dump(exclude_none=True)
|
||||
if not role_at_least(user.role, "editor"):
|
||||
eu.pop("roles", None)
|
||||
eu.pop("entitlements", None)
|
||||
return eu
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def project_run(
|
||||
project_id: str,
|
||||
body: ProjectRunIn,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
run_service: RunService = Depends(get_run_service),
|
||||
rc: dict | None = Depends(run_context),
|
||||
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
):
|
||||
tenant_id = user.tenant_id
|
||||
# Per-tenant run-creation rate limit (shared bucket with the per-workflow run API).
|
||||
if not rate_limiter.allow(f"runs:{tenant_id}", rate=settings.run_rate_limit_per_minute, per=60):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "run rate limit exceeded; slow down")
|
||||
|
||||
# Idempotency for the blind-retry-prone case: a non-streaming new turn re-POSTed with the
|
||||
# same Idempotency-Key returns the original result instead of running the workflow again
|
||||
# (duplicate emails/charges/side-effects). Streaming and HITL-resume are live/thread-stateful
|
||||
# and are not deduped here. Checked BEFORE run creation so the side-effecting run never fires
|
||||
# twice; the result is stored after completion below.
|
||||
ik = (
|
||||
f"projrun:{tenant_id}:{project_id}:{idempotency_key}"
|
||||
if (idempotency_key and body.resume is None and not body.stream)
|
||||
else None
|
||||
)
|
||||
if ik:
|
||||
cached = idempotency.get(ik)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# ---- HITL resume: answer an interrupt the workflow raised on this thread ----
|
||||
if body.resume is not None:
|
||||
if not body.thread_id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"resume requires the thread_id of the interrupted conversation",
|
||||
)
|
||||
# Resolve the interrupted run by either thread handle the caller may hold - the DB
|
||||
# Thread.id or the composite LangGraph id - mirroring create_run's thread reuse.
|
||||
run = (await session.execute(
|
||||
select(Run).join(Thread, Thread.id == Run.thread_id).where(
|
||||
Run.tenant_id == tenant_id, Run.project_id == project_id,
|
||||
or_(Thread.id == body.thread_id, Thread.lg_thread_id == body.thread_id),
|
||||
Run.status == "interrupted",
|
||||
).order_by(Run.created_at.desc())
|
||||
)).scalars().first()
|
||||
if run is None:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "no interrupted run to resume on this thread")
|
||||
value = body.resume.value
|
||||
if body.stream:
|
||||
async def gen_resume():
|
||||
# Lead with the canonical thread_id so the streaming caller can keep the
|
||||
# conversation going (the run frame from stream() carries the LangGraph id).
|
||||
yield {"event": "ready", "data": json.dumps({"run_id": run.id, "thread_id": run.thread_id})}
|
||||
async for frame in run_service.stream(
|
||||
run_id=run.id, tenant_id=tenant_id, project_id=project_id,
|
||||
run_context=rc, resume=True, resume_value=value,
|
||||
):
|
||||
# `id` lets a disconnected client reattach via GET .../runs/{run_id}/stream
|
||||
# with Last-Event-ID (the run keeps executing regardless - finding #12).
|
||||
yield {"event": frame["event"], "data": json.dumps(frame["data"], default=str), "id": frame.get("id")}
|
||||
|
||||
return EventSourceResponse(gen_resume(), headers=SSE_HEADERS)
|
||||
result = await run_service.resume(
|
||||
run_id=run.id, tenant_id=tenant_id, value=value, project_id=project_id, run_context=rc,
|
||||
)
|
||||
result.setdefault("run_id", run.id)
|
||||
result["thread_id"] = run.thread_id
|
||||
return result
|
||||
|
||||
# ---- new turn: run the project's configured workflow ----
|
||||
wf = await _configured_workflow(session, tenant_id, project_id)
|
||||
end_user = _resolve_end_user(body, user, tenant_id, project_id)
|
||||
# Enforce the tenant DAILY quota atomically with run creation (audit F2).
|
||||
from forge.services.budget import BudgetExceeded, ModelNotAllowed
|
||||
from forge.services.quota import QuotaExceeded, run_admission
|
||||
|
||||
try:
|
||||
async with run_admission(session, tenant_id):
|
||||
run = await run_service.create_run(
|
||||
session, tenant_id=tenant_id, project_id=project_id, workflow_id=wf.id,
|
||||
input=body.input or {}, thread_id=body.thread_id, end_user=end_user, source="api",
|
||||
)
|
||||
except QuotaExceeded as e:
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, e.message) from e
|
||||
except ModelNotAllowed as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, e.message) from e
|
||||
except BudgetExceeded as e:
|
||||
raise HTTPException(status.HTTP_402_PAYMENT_REQUIRED, e.message) from e
|
||||
|
||||
if body.stream:
|
||||
async def gen_new():
|
||||
yield {"event": "ready", "data": json.dumps({"run_id": run.id, "thread_id": run.thread_id})}
|
||||
async for frame in run_service.stream(
|
||||
run_id=run.id, tenant_id=tenant_id, project_id=project_id, run_context=rc,
|
||||
):
|
||||
# `id` lets a disconnected client reattach via GET .../runs/{run_id}/stream
|
||||
# with Last-Event-ID (the run keeps executing regardless - finding #12).
|
||||
yield {"event": frame["event"], "data": json.dumps(frame["data"], default=str), "id": frame.get("id")}
|
||||
|
||||
return EventSourceResponse(gen_new(), headers=SSE_HEADERS)
|
||||
|
||||
result = await run_service.run_to_completion(
|
||||
run_id=run.id, tenant_id=tenant_id, project_id=project_id, run_context=rc,
|
||||
)
|
||||
result["thread_id"] = run.thread_id
|
||||
if ik:
|
||||
idempotency.put(ik, result)
|
||||
return result
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Project endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import CurrentUser, client_ip, current_tenant_id, get_session, require_role
|
||||
from forge.schemas.dto import ProjectCountsOut, ProjectCreate, ProjectOut, ProjectUpdate
|
||||
from forge.services.audit import AuditService
|
||||
from forge.services.projects import ProjectService
|
||||
from forge.services.versions import safe_snapshot
|
||||
|
||||
router = APIRouter(prefix="/v1/projects", tags=["projects"])
|
||||
|
||||
|
||||
class ProjectMemberIn(BaseModel):
|
||||
role: str # owner|admin|editor|viewer - the caller's per-project role for {user_id}
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProjectOut])
|
||||
async def list_projects(
|
||||
session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)
|
||||
):
|
||||
return await ProjectService.list(session, tenant_id)
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectOut, status_code=201)
|
||||
async def create_project(
|
||||
body: ProjectCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
user: CurrentUser = Depends(require_role("admin")),
|
||||
):
|
||||
project = await ProjectService.create(
|
||||
session, tenant_id, name=body.name, slug=body.slug,
|
||||
description=body.description, config=body.config,
|
||||
)
|
||||
await safe_snapshot(session, "project", project, author=user)
|
||||
return project
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectOut)
|
||||
async def get_project(
|
||||
project_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
):
|
||||
project = await ProjectService.get(session, tenant_id, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(404, "Project not found")
|
||||
return project
|
||||
|
||||
|
||||
@router.get("/{project_id}/counts", response_model=ProjectCountsOut)
|
||||
async def project_counts(
|
||||
project_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
):
|
||||
"""Lightweight per-resource counts for the project sidebar badges
|
||||
({workflows, agents, tools, components, knowledge, auth})."""
|
||||
return await ProjectService.counts(session, tenant_id, project_id)
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=ProjectOut)
|
||||
async def update_project(
|
||||
project_id: str,
|
||||
body: ProjectUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
user: CurrentUser = Depends(require_role("admin")),
|
||||
):
|
||||
project = await ProjectService.get(session, tenant_id, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(404, "Project not found")
|
||||
project = await ProjectService.update(session, project, name=body.name, description=body.description, config=body.config)
|
||||
await safe_snapshot(session, "project", project, author=user)
|
||||
return project
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=204)
|
||||
async def delete_project(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("admin")),
|
||||
):
|
||||
project = await ProjectService.get(session, tenant_id, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(404, "Project not found")
|
||||
await ProjectService.delete(session, project, checkpointer=getattr(request.app.state, "checkpointer", None))
|
||||
|
||||
|
||||
# --- per-project membership / RBAC (finding h) ---
|
||||
@router.get("/{project_id}/members")
|
||||
async def list_project_members(
|
||||
project_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("admin")),
|
||||
):
|
||||
members = await ProjectService.list_members(session, tenant_id, project_id)
|
||||
return [{"user_id": m.user_id, "role": m.role} for m in members]
|
||||
|
||||
|
||||
@router.put("/{project_id}/members/{user_id}")
|
||||
async def set_project_member(
|
||||
project_id: str,
|
||||
user_id: str,
|
||||
body: ProjectMemberIn,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
admin: CurrentUser = Depends(require_role("admin")),
|
||||
):
|
||||
"""Grant/update a user's role ON THIS PROJECT. Elevates their tenant-wide role for this
|
||||
project only (never demotes it - see deps.effective_role)."""
|
||||
if await ProjectService.get(session, tenant_id, project_id) is None:
|
||||
raise HTTPException(404, "Project not found")
|
||||
try:
|
||||
m = await ProjectService.set_member(session, tenant_id=tenant_id, project_id=project_id,
|
||||
user_id=user_id, role=body.role)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
await AuditService.log(tenant_id=tenant_id, action="project.member.set", actor_id=admin.id,
|
||||
actor_email=admin.email, resource_type="project", resource_id=project_id,
|
||||
project_id=project_id, ip=client_ip(request),
|
||||
meta={"user_id": user_id, "role": body.role})
|
||||
return {"user_id": m.user_id, "role": m.role}
|
||||
|
||||
|
||||
@router.delete("/{project_id}/members/{user_id}", status_code=204)
|
||||
async def remove_project_member(
|
||||
project_id: str,
|
||||
user_id: str,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
admin: CurrentUser = Depends(require_role("admin")),
|
||||
):
|
||||
if not await ProjectService.remove_member(session, tenant_id=tenant_id, project_id=project_id, user_id=user_id):
|
||||
raise HTTPException(404, "membership not found")
|
||||
await AuditService.log(tenant_id=tenant_id, action="project.member.remove", actor_id=admin.id,
|
||||
actor_email=admin.email, resource_type="project", resource_id=project_id,
|
||||
project_id=project_id, ip=client_ip(request), meta={"user_id": user_id})
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Run endpoints - create a run and stream its execution over SSE."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from forge.config import settings
|
||||
from forge.deps import (
|
||||
CurrentUser,
|
||||
current_tenant_id,
|
||||
get_current_user,
|
||||
get_run_service,
|
||||
get_session,
|
||||
run_context,
|
||||
)
|
||||
from forge.schemas.dto import ResumeIn, RunCreate, RunOut
|
||||
from forge.services.runs import RunService
|
||||
from forge.util.ratelimit import idempotency, rate_limiter
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/workflows/{workflow_id}/runs", tags=["runs"])
|
||||
|
||||
SSE_HEADERS = {
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
|
||||
|
||||
@router.post("", response_model=RunOut, status_code=201)
|
||||
async def create_run(
|
||||
project_id: str,
|
||||
workflow_id: str,
|
||||
body: RunCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
run_service: RunService = Depends(get_run_service),
|
||||
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
):
|
||||
tenant_id = user.tenant_id
|
||||
# Idempotency: a retried POST with the same key returns the original run instead
|
||||
# of starting a duplicate (important for at-least-once callers / channel webhooks).
|
||||
if idempotency_key:
|
||||
cache_key = f"run:{tenant_id}:{idempotency_key}"
|
||||
cached = idempotency.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Per-tenant run-creation rate limit.
|
||||
if not rate_limiter.allow(f"runs:{tenant_id}", rate=settings.run_rate_limit_per_minute, per=60):
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "run rate limit exceeded; slow down")
|
||||
|
||||
# This is the CONSOLE interactive run surface (Playground / Workflow test), so every run acts as
|
||||
# the LOGGED-IN operator - their identity is authoritative and is NOT overridable from the body.
|
||||
# That makes per-user auth providers resolve the operator's own connected credential
|
||||
# (end_user_id = user.id), exactly like the tool /test does; the run also carries who is chatting
|
||||
# for analytics. The server-to-server Run API (routers/project_run.py) and the public embed
|
||||
# surface (routers/embed_public.py) are where a different end user is asserted. A machine
|
||||
# principal (service token / API key) carries no per-user identity, so it runs without one.
|
||||
end_user = None
|
||||
if not str(user.id).startswith(("apikey:", "service")):
|
||||
end_user = {"id": user.id, "email": user.email}
|
||||
|
||||
# Per-tenant DAILY quota, enforced atomically with run creation so concurrent POSTs
|
||||
# can't all pass a stale pre-insert count (audit F2).
|
||||
from forge.services.budget import BudgetExceeded, ModelNotAllowed
|
||||
from forge.services.quota import QuotaExceeded, run_admission
|
||||
try:
|
||||
async with run_admission(session, tenant_id):
|
||||
run = await run_service.create_run(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
project_id=project_id,
|
||||
workflow_id=workflow_id,
|
||||
input=body.input or {},
|
||||
thread_id=body.thread_id,
|
||||
end_user=end_user,
|
||||
source="playground",
|
||||
)
|
||||
except QuotaExceeded as e:
|
||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, e.message) from e
|
||||
except ModelNotAllowed as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, e.message) from e
|
||||
except BudgetExceeded as e:
|
||||
raise HTTPException(status.HTTP_402_PAYMENT_REQUIRED, e.message) from e
|
||||
out = RunOut(id=run.id, status=run.status, thread_id=run.thread_id)
|
||||
if idempotency_key:
|
||||
idempotency.put(f"run:{tenant_id}:{idempotency_key}", out)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/{run_id}/stream")
|
||||
async def stream_run(
|
||||
project_id: str,
|
||||
workflow_id: str,
|
||||
run_id: str,
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
run_service: RunService = Depends(get_run_service),
|
||||
rc: dict | None = Depends(run_context),
|
||||
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
|
||||
):
|
||||
# Reconnect/reattach: a browser's EventSource resends the last id it saw as Last-Event-ID;
|
||||
# we replay frames after it then follow live. Absent/garbage -> start from the beginning.
|
||||
start_from = int(last_event_id) if (last_event_id or "").isdigit() else 0
|
||||
|
||||
async def event_gen():
|
||||
async for frame in run_service.stream(
|
||||
run_id=run_id, tenant_id=tenant_id, project_id=project_id, run_context=rc,
|
||||
last_event_id=start_from,
|
||||
):
|
||||
yield {"event": frame["event"], "data": json.dumps(frame["data"], default=str), "id": frame.get("id")}
|
||||
|
||||
return EventSourceResponse(event_gen(), headers=SSE_HEADERS)
|
||||
|
||||
|
||||
@router.post("/{run_id}/rerun", response_model=RunOut, status_code=201)
|
||||
async def rerun(
|
||||
project_id: str,
|
||||
workflow_id: str,
|
||||
run_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
run_service: RunService = Depends(get_run_service),
|
||||
):
|
||||
"""Replay a past run: create a new run with the same input (fresh thread)."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from forge.models import Run
|
||||
|
||||
orig = (await session.execute(select(Run).where(
|
||||
Run.tenant_id == tenant_id,
|
||||
Run.project_id == project_id,
|
||||
Run.workflow_id == workflow_id,
|
||||
Run.id == run_id,
|
||||
))).scalar_one_or_none()
|
||||
if orig is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "run not found")
|
||||
run = await run_service.create_run(
|
||||
session, tenant_id=tenant_id, project_id=project_id, workflow_id=workflow_id, input=orig.input or {},
|
||||
source=getattr(orig, "source", None) or "playground",
|
||||
)
|
||||
return RunOut(id=run.id, status=run.status, thread_id=run.thread_id)
|
||||
|
||||
|
||||
@router.post("/{run_id}/resume")
|
||||
async def resume_run(
|
||||
project_id: str,
|
||||
workflow_id: str,
|
||||
run_id: str,
|
||||
body: ResumeIn,
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
run_service: RunService = Depends(get_run_service),
|
||||
rc: dict | None = Depends(run_context),
|
||||
):
|
||||
return await run_service.resume(run_id=run_id, tenant_id=tenant_id, value=body.value, project_id=project_id, run_context=rc)
|
||||
|
||||
|
||||
@router.post("/{run_id}/cancel")
|
||||
async def cancel_run(
|
||||
project_id: str,
|
||||
workflow_id: str,
|
||||
run_id: str,
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
run_service: RunService = Depends(get_run_service),
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Cancel a run: mark it canceled and cooperatively stop it (frees the tenant-concurrency
|
||||
slot). A terminal run (done/error/canceled) returns ok=False with its current status."""
|
||||
result = await run_service.cancel_run(run_id=run_id, tenant_id=tenant_id, project_id=project_id)
|
||||
if result.get("error") == "run not found":
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "run not found")
|
||||
return result
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Secret endpoints - write-only; plaintext is never returned."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
|
||||
from forge.schemas.dto import SecretCreate, SecretOut
|
||||
from forge.services.secrets import SecretService
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/secrets", tags=["secrets"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[SecretOut])
|
||||
async def list_secrets(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
return await SecretService.list(session, tenant_id, project_id)
|
||||
|
||||
|
||||
@router.post("", response_model=SecretOut, status_code=201)
|
||||
async def create_secret(project_id: str, body: SecretCreate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("admin"))):
|
||||
return await SecretService.write(session, tenant_id, project_id, name=body.name, value=body.value, kind=body.kind)
|
||||
|
||||
|
||||
@router.get("/{name}/usage")
|
||||
async def secret_usage(project_id: str, name: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
refs = await SecretService.usage(session, tenant_id, project_id, name=name)
|
||||
return {"count": len(refs), "references": refs}
|
||||
|
||||
|
||||
@router.delete("/{name}", status_code=204)
|
||||
async def delete_secret(project_id: str, name: str, force: bool = False, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("admin"))):
|
||||
if not force:
|
||||
refs = await SecretService.usage(session, tenant_id, project_id, name=name)
|
||||
if refs:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, detail={"message": "Secret is in use", "references": refs})
|
||||
removed = await SecretService.delete(session, tenant_id, project_id, name=name)
|
||||
if not removed:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Secret not found")
|
||||
@@ -0,0 +1,535 @@
|
||||
"""Dashboard stats - tenant-wide rollups over real traces (no placeholder numbers).
|
||||
|
||||
Rollups are computed as SQL aggregates (COUNT / SUM + GROUP BY) so a dashboard load never
|
||||
pulls a tenant's entire trace history into memory - it returns a handful of grouped rows
|
||||
regardless of how many traces exist. Derived fields (averages, rates) are computed in
|
||||
Python from the raw sums/counts so the arithmetic stays identical to the previous
|
||||
row-by-row implementation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.db.base import engine
|
||||
from forge.deps import current_tenant_id, get_session
|
||||
from forge.models import Project, Span, Tool, Trace, Workflow
|
||||
|
||||
router = APIRouter(prefix="/v1/stats", tags=["stats"])
|
||||
|
||||
# Postgres (prod) and SQLite (dev/test) format dates differently. Bucket a timestamp to a
|
||||
# "YYYY-MM-DD" string in SQL - grouped in the database, so a chart load never pulls a
|
||||
# project's whole trace history into memory - via the right function for the active dialect.
|
||||
_DIALECT = engine.dialect.name
|
||||
|
||||
|
||||
def _day_bucket(col):
|
||||
if _DIALECT == "sqlite":
|
||||
return func.strftime("%Y-%m-%d", col)
|
||||
return func.to_char(func.date_trunc("day", col), "YYYY-MM-DD")
|
||||
|
||||
|
||||
# --- response models (typed contract for the generated OpenAPI schema) --------------------
|
||||
|
||||
class RollupOut(BaseModel):
|
||||
runs: int
|
||||
tokens: int
|
||||
cost_usd: float
|
||||
avg_latency_ms: int
|
||||
errors: int
|
||||
error_rate: float
|
||||
|
||||
|
||||
class RecentRunOut(BaseModel):
|
||||
id: str
|
||||
workflow: str
|
||||
project: str
|
||||
status: str
|
||||
tokens: int
|
||||
latency_ms: int
|
||||
cost_usd: float
|
||||
started_at: str | None = None
|
||||
|
||||
|
||||
class ProjectCardStatsOut(BaseModel):
|
||||
workflows: int
|
||||
tools: int
|
||||
runs_7d: int
|
||||
|
||||
|
||||
class DashboardReportOut(RollupOut):
|
||||
project_id: str
|
||||
project: str
|
||||
assistant_cost_usd: float
|
||||
assistant_turns: int
|
||||
|
||||
|
||||
class DashboardStatsOut(BaseModel):
|
||||
runs_7d: int
|
||||
total_runs: int
|
||||
success_rate: float
|
||||
avg_latency_ms: int
|
||||
spend_7d: float
|
||||
recent: list[RecentRunOut]
|
||||
projects: dict[str, ProjectCardStatsOut]
|
||||
reports: list[DashboardReportOut]
|
||||
totals: RollupOut
|
||||
|
||||
|
||||
class ReportRowOut(RollupOut):
|
||||
label: str
|
||||
kind: str
|
||||
|
||||
|
||||
class AssistantRollupOut(RollupOut):
|
||||
turns: int
|
||||
|
||||
|
||||
class ProjectStatsOut(BaseModel):
|
||||
totals: RollupOut
|
||||
last_7d: RollupOut
|
||||
assistant: AssistantRollupOut
|
||||
reports: list[ReportRowOut]
|
||||
|
||||
|
||||
# --- analytics dashboard (time-series + breakdowns over a date range) ---------------------
|
||||
|
||||
class TimeBucketOut(BaseModel):
|
||||
date: str # "YYYY-MM-DD" (one point per day across the whole range, gaps zero-filled)
|
||||
runs: int
|
||||
tokens: int
|
||||
cost_usd: float
|
||||
avg_latency_ms: int
|
||||
errors: int
|
||||
success: int
|
||||
|
||||
|
||||
class SourceRollupOut(RollupOut):
|
||||
source: str
|
||||
|
||||
|
||||
class ToolStatOut(BaseModel):
|
||||
name: str
|
||||
calls: int
|
||||
avg_latency_ms: int
|
||||
errors: int
|
||||
cost_usd: float
|
||||
tokens: int
|
||||
|
||||
|
||||
class ModelStatOut(BaseModel):
|
||||
model: str
|
||||
calls: int
|
||||
tokens: int
|
||||
cost_usd: float
|
||||
avg_latency_ms: int
|
||||
|
||||
|
||||
class LatencyBucketOut(BaseModel):
|
||||
label: str
|
||||
count: int
|
||||
|
||||
|
||||
class AnalyticsRangeOut(BaseModel):
|
||||
days: int
|
||||
since: str
|
||||
until: str
|
||||
bucket: str
|
||||
|
||||
|
||||
class AnalyticsOut(BaseModel):
|
||||
range: AnalyticsRangeOut
|
||||
totals: RollupOut # windowed over the selected range
|
||||
prev_totals: RollupOut # the immediately-preceding window of equal length (for deltas)
|
||||
timeseries: list[TimeBucketOut]
|
||||
by_source: list[SourceRollupOut]
|
||||
by_workflow: list[ReportRowOut]
|
||||
tools: list[ToolStatOut]
|
||||
models: list[ModelStatOut]
|
||||
latency_histogram: list[LatencyBucketOut]
|
||||
recent: list[RecentRunOut]
|
||||
|
||||
# When a trace has no start time, fall back to its insert time (matches the old
|
||||
# `t.started_at or t.created_at`) for the 7-day activity window.
|
||||
_ACTIVITY = func.coalesce(Trace.started_at, Trace.created_at)
|
||||
|
||||
|
||||
def _agg_columns():
|
||||
"""The five raw aggregates every rollup needs. Derived fields (avg, rate) come from these.
|
||||
Returned fresh each call so the same expressions can be reused across queries."""
|
||||
return (
|
||||
func.count().label("runs"),
|
||||
func.coalesce(func.sum(Trace.total_tokens), 0).label("tokens"),
|
||||
func.coalesce(func.sum(Trace.total_cost_usd), 0.0).label("cost"),
|
||||
func.coalesce(func.sum(Trace.latency_ms), 0).label("latency_sum"),
|
||||
func.coalesce(func.sum(case((Trace.status == "error", 1), else_=0)), 0).label("errors"),
|
||||
)
|
||||
|
||||
|
||||
def _rollup(row) -> dict:
|
||||
"""Fold one aggregate row (runs/tokens/cost/latency_sum/errors) into the rollup shape."""
|
||||
runs = int(row.runs or 0)
|
||||
errors = int(row.errors or 0)
|
||||
return {
|
||||
"runs": runs,
|
||||
"tokens": int(row.tokens or 0),
|
||||
"cost_usd": round(float(row.cost or 0.0), 6),
|
||||
"avg_latency_ms": int((row.latency_sum or 0) / runs) if runs else 0,
|
||||
"errors": errors,
|
||||
"error_rate": round(errors / runs * 100, 1) if runs else 0.0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=DashboardStatsOut)
|
||||
async def dashboard(session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
since = datetime.utcnow() - timedelta(days=7)
|
||||
tenant = Trace.tenant_id == tenant_id
|
||||
|
||||
# All-time totals (one row).
|
||||
totals = _rollup((await session.execute(select(*_agg_columns()).where(tenant))).one())
|
||||
|
||||
# 7-day window aggregate (one row): count, success count, spend, latency sum.
|
||||
win = (await session.execute(
|
||||
select(
|
||||
func.count().label("runs"),
|
||||
func.coalesce(func.sum(case((Trace.status.in_(("done", "interrupted")), 1), else_=0)), 0).label("done"),
|
||||
func.coalesce(func.sum(Trace.total_cost_usd), 0.0).label("cost"),
|
||||
func.coalesce(func.sum(Trace.latency_ms), 0).label("latency_sum"),
|
||||
).where(tenant, _ACTIVITY >= since)
|
||||
)).one()
|
||||
total = int(win.runs or 0)
|
||||
done = int(win.done or 0)
|
||||
avg_latency = int((win.latency_sum or 0) / total) if total else 0
|
||||
|
||||
# Per-project counts for the dashboard cards (workflows, tools, 7-day runs).
|
||||
per_project: dict[str, dict] = {}
|
||||
|
||||
def bucket(pid: str) -> dict:
|
||||
return per_project.setdefault(pid, {"workflows": 0, "tools": 0, "runs_7d": 0})
|
||||
|
||||
for pid, n in (await session.execute(
|
||||
select(Workflow.project_id, func.count()).where(Workflow.tenant_id == tenant_id).group_by(Workflow.project_id)
|
||||
)).all():
|
||||
bucket(pid)["workflows"] = int(n)
|
||||
for pid, n in (await session.execute(
|
||||
select(Tool.project_id, func.count()).where(Tool.tenant_id == tenant_id).group_by(Tool.project_id)
|
||||
)).all():
|
||||
bucket(pid)["tools"] = int(n)
|
||||
for pid, n in (await session.execute(
|
||||
select(Trace.project_id, func.count()).where(tenant, _ACTIVITY >= since).group_by(Trace.project_id)
|
||||
)).all():
|
||||
bucket(pid)["runs_7d"] = int(n)
|
||||
|
||||
# Name lookups (bounded by #workflows / #projects, not #traces).
|
||||
wf_names: dict[str, str] = {wid: name for wid, name in (await session.execute(
|
||||
select(Workflow.id, Workflow.name).where(Workflow.tenant_id == tenant_id)
|
||||
)).all()}
|
||||
proj_names: dict[str, str] = {pid: name for pid, name in (await session.execute(
|
||||
select(Project.id, Project.name).where(Project.tenant_id == tenant_id)
|
||||
)).all()}
|
||||
|
||||
# 8 most recent all-time - only the columns the card renders.
|
||||
recent_rows = (await session.execute(
|
||||
select(
|
||||
Trace.id, Trace.workflow_id, Trace.project_id, Trace.status,
|
||||
Trace.total_tokens, Trace.latency_ms, Trace.total_cost_usd, Trace.started_at,
|
||||
).where(tenant).order_by(Trace.started_at.desc()).limit(8)
|
||||
)).all()
|
||||
recent = [
|
||||
{
|
||||
"id": r.id,
|
||||
"workflow": wf_names.get(r.workflow_id or "", "run"),
|
||||
"project": proj_names.get(r.project_id, "-"),
|
||||
"status": r.status,
|
||||
"tokens": r.total_tokens,
|
||||
"latency_ms": r.latency_ms,
|
||||
"cost_usd": round(r.total_cost_usd or 0.0, 6),
|
||||
"started_at": r.started_at.isoformat() if r.started_at else None,
|
||||
}
|
||||
for r in recent_rows
|
||||
]
|
||||
|
||||
# Per-project report rows (all-time); assistant share via conditional aggregation.
|
||||
report_rows = (await session.execute(
|
||||
select(
|
||||
Trace.project_id,
|
||||
*_agg_columns(),
|
||||
func.coalesce(func.sum(case((Trace.name == "assistant", Trace.total_cost_usd), else_=0.0)), 0.0).label("asst_cost"),
|
||||
func.coalesce(func.sum(case((Trace.name == "assistant", 1), else_=0)), 0).label("asst_turns"),
|
||||
).where(tenant).group_by(Trace.project_id)
|
||||
)).all()
|
||||
reports = [
|
||||
{
|
||||
"project_id": r.project_id,
|
||||
"project": proj_names.get(r.project_id, "(deleted project)"),
|
||||
**_rollup(r),
|
||||
"assistant_cost_usd": round(float(r.asst_cost or 0.0), 6),
|
||||
"assistant_turns": int(r.asst_turns or 0),
|
||||
}
|
||||
for r in report_rows
|
||||
]
|
||||
reports.sort(key=lambda r: r["cost_usd"], reverse=True)
|
||||
|
||||
return {
|
||||
"runs_7d": total,
|
||||
"total_runs": totals["runs"],
|
||||
"success_rate": round(done / total * 100, 1) if total else 0.0,
|
||||
"avg_latency_ms": avg_latency,
|
||||
"spend_7d": round(float(win.cost or 0.0), 6),
|
||||
"recent": recent,
|
||||
"projects": per_project,
|
||||
"reports": reports,
|
||||
"totals": totals,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=ProjectStatsOut)
|
||||
async def project_stats(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
"""Project-scoped rollups + report rows (per workflow + Forge Assistant)."""
|
||||
since = datetime.utcnow() - timedelta(days=7)
|
||||
scope = (Trace.tenant_id == tenant_id, Trace.project_id == project_id)
|
||||
|
||||
totals = _rollup((await session.execute(select(*_agg_columns()).where(*scope))).one())
|
||||
last_7d = _rollup((await session.execute(select(*_agg_columns()).where(*scope, _ACTIVITY >= since))).one())
|
||||
asst = (await session.execute(select(*_agg_columns()).where(*scope, Trace.name == "assistant"))).one()
|
||||
assistant = {**_rollup(asst), "turns": int(asst.runs or 0)}
|
||||
|
||||
wf_names: dict[str, str] = {wid: name for wid, name in (await session.execute(
|
||||
select(Workflow.id, Workflow.name).where(Workflow.tenant_id == tenant_id, Workflow.project_id == project_id)
|
||||
)).all()}
|
||||
|
||||
# Report rows grouped like the old _report_rows: one bucket for the assistant, one per
|
||||
# workflow_id, and an "other" bucket keyed by trace name for runs with no workflow.
|
||||
kind = case(
|
||||
(Trace.name == "assistant", "assistant"),
|
||||
(Trace.workflow_id.isnot(None), "workflow"),
|
||||
else_="other",
|
||||
).label("kind")
|
||||
ident = case(
|
||||
(Trace.name == "assistant", "assistant"),
|
||||
(Trace.workflow_id.isnot(None), Trace.workflow_id),
|
||||
else_=Trace.name,
|
||||
).label("ident")
|
||||
grouped = (await session.execute(
|
||||
select(kind, ident, *_agg_columns()).where(*scope).group_by(kind, ident)
|
||||
)).all()
|
||||
reports = []
|
||||
for r in grouped:
|
||||
if r.kind == "assistant":
|
||||
label = "Forge Assistant"
|
||||
elif r.kind == "workflow":
|
||||
label = wf_names.get(r.ident, "(deleted workflow)")
|
||||
else:
|
||||
label = r.ident
|
||||
reports.append({"label": label, "kind": r.kind, **_rollup(r)})
|
||||
reports.sort(key=lambda r: r["cost_usd"], reverse=True)
|
||||
|
||||
return {
|
||||
"totals": totals,
|
||||
"last_7d": last_7d,
|
||||
"assistant": assistant,
|
||||
"reports": reports,
|
||||
}
|
||||
|
||||
|
||||
# Fixed latency buckets for the distribution histogram (upper bound in ms; None = open-ended).
|
||||
_LATENCY_BUCKETS: list[tuple[str, int | None]] = [
|
||||
("<250ms", 250), ("250-500ms", 500), ("500ms-1s", 1000), ("1-2s", 2000),
|
||||
("2-5s", 5000), ("5-10s", 10000), (">10s", None),
|
||||
]
|
||||
|
||||
|
||||
def _ts_row(row) -> dict:
|
||||
"""Fold one daily aggregate row into a time-series point (adds a success count)."""
|
||||
runs = int(row.runs or 0)
|
||||
return {
|
||||
"date": row.day,
|
||||
"runs": runs,
|
||||
"tokens": int(row.tokens or 0),
|
||||
"cost_usd": round(float(row.cost or 0.0), 6),
|
||||
"avg_latency_ms": int((row.latency_sum or 0) / runs) if runs else 0,
|
||||
"errors": int(row.errors or 0),
|
||||
"success": int(row.success or 0),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/analytics", response_model=AnalyticsOut)
|
||||
async def project_analytics(
|
||||
project_id: str,
|
||||
days: int = 30,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
):
|
||||
"""Time-series + breakdowns for the project Analytics dashboard, over the last `days`.
|
||||
|
||||
Everything is a grouped SQL aggregate (daily buckets, per-source, per-workflow, and
|
||||
per-tool/per-model spans) so a dashboard load returns a bounded number of rows no matter
|
||||
how large the trace history is. The previous equal-length window is rolled up too, so the
|
||||
UI can show period-over-period deltas on each KPI.
|
||||
"""
|
||||
days = max(1, min(days, 365))
|
||||
now = datetime.utcnow()
|
||||
since = now - timedelta(days=days)
|
||||
prev_since = since - timedelta(days=days)
|
||||
scope = (Trace.tenant_id == tenant_id, Trace.project_id == project_id)
|
||||
span_scope = (Trace.tenant_id == tenant_id, Trace.project_id == project_id, _ACTIVITY >= since)
|
||||
|
||||
totals = _rollup((await session.execute(select(*_agg_columns()).where(*scope, _ACTIVITY >= since))).one())
|
||||
prev_totals = _rollup(
|
||||
(await session.execute(select(*_agg_columns()).where(*scope, _ACTIVITY >= prev_since, _ACTIVITY < since))).one()
|
||||
)
|
||||
|
||||
# Daily time-series: one grouped row per calendar day present, then zero-fill the gaps so
|
||||
# the chart draws a continuous line across the whole range.
|
||||
day = _day_bucket(_ACTIVITY).label("day")
|
||||
ts_rows = (await session.execute(
|
||||
select(
|
||||
day, *_agg_columns(),
|
||||
func.coalesce(func.sum(case((Trace.status.in_(("done", "interrupted")), 1), else_=0)), 0).label("success"),
|
||||
).where(*scope, _ACTIVITY >= since).group_by(day).order_by(day)
|
||||
)).all()
|
||||
by_day = {r.day: _ts_row(r) for r in ts_rows}
|
||||
timeseries: list[dict] = []
|
||||
cursor = since.date()
|
||||
end = now.date()
|
||||
while cursor <= end:
|
||||
key = cursor.isoformat()
|
||||
timeseries.append(by_day.get(key) or {
|
||||
"date": key, "runs": 0, "tokens": 0, "cost_usd": 0.0, "avg_latency_ms": 0, "errors": 0, "success": 0,
|
||||
})
|
||||
cursor += timedelta(days=1)
|
||||
|
||||
# Per-source rollup (playground / api / embed / channels / assistant / ...).
|
||||
src_rows = (await session.execute(
|
||||
select(Trace.source, *_agg_columns()).where(*scope, _ACTIVITY >= since).group_by(Trace.source)
|
||||
)).all()
|
||||
by_source = [{"source": r.source or "-", **_rollup(r)} for r in src_rows]
|
||||
by_source.sort(key=lambda r: r["runs"], reverse=True)
|
||||
|
||||
# Per-workflow (+ assistant + name-keyed "other") report rows, same shape as project_stats.
|
||||
kind = case(
|
||||
(Trace.name == "assistant", "assistant"),
|
||||
(Trace.workflow_id.isnot(None), "workflow"),
|
||||
else_="other",
|
||||
).label("kind")
|
||||
ident = case(
|
||||
(Trace.name == "assistant", "assistant"),
|
||||
(Trace.workflow_id.isnot(None), Trace.workflow_id),
|
||||
else_=Trace.name,
|
||||
).label("ident")
|
||||
grouped = (await session.execute(
|
||||
select(kind, ident, *_agg_columns()).where(*scope, _ACTIVITY >= since).group_by(kind, ident)
|
||||
)).all()
|
||||
wf_names: dict[str, str] = {wid: name for wid, name in (await session.execute(
|
||||
select(Workflow.id, Workflow.name).where(Workflow.tenant_id == tenant_id, Workflow.project_id == project_id)
|
||||
)).all()}
|
||||
by_workflow = []
|
||||
for r in grouped:
|
||||
if r.kind == "assistant":
|
||||
label = "Forge Assistant"
|
||||
elif r.kind == "workflow":
|
||||
label = wf_names.get(r.ident, "(deleted workflow)")
|
||||
else:
|
||||
label = r.ident
|
||||
by_workflow.append({"label": label, "kind": r.kind, **_rollup(r)})
|
||||
by_workflow.sort(key=lambda r: r["cost_usd"], reverse=True)
|
||||
|
||||
# Tool + model breakdowns from spans, joined to their trace for tenant/project/window scope.
|
||||
span_lat = func.coalesce(func.sum(Span.latency_ms), 0).label("latency_sum")
|
||||
span_calls = func.count().label("calls")
|
||||
span_tokens = func.coalesce(func.sum(Span.input_tokens + Span.output_tokens), 0).label("tokens")
|
||||
span_cost = func.coalesce(func.sum(Span.cost_usd), 0.0).label("cost")
|
||||
|
||||
tool_rows = (await session.execute(
|
||||
select(
|
||||
Span.name, span_calls, span_lat, span_tokens, span_cost,
|
||||
func.coalesce(func.sum(case((Span.error.isnot(None), 1), else_=0)), 0).label("errors"),
|
||||
).join(Trace, Trace.id == Span.trace_id)
|
||||
.where(*span_scope, Span.kind == "tool")
|
||||
.group_by(Span.name).order_by(span_calls.desc()).limit(12)
|
||||
)).all()
|
||||
tools = [
|
||||
{
|
||||
"name": r.name,
|
||||
"calls": int(r.calls or 0),
|
||||
"avg_latency_ms": int((r.latency_sum or 0) / r.calls) if r.calls else 0,
|
||||
"errors": int(r.errors or 0),
|
||||
"cost_usd": round(float(r.cost or 0.0), 6),
|
||||
"tokens": int(r.tokens or 0),
|
||||
}
|
||||
for r in tool_rows
|
||||
]
|
||||
|
||||
model_rows = (await session.execute(
|
||||
select(Span.model, span_calls, span_lat, span_tokens, span_cost)
|
||||
.join(Trace, Trace.id == Span.trace_id)
|
||||
.where(*span_scope, Span.kind == "llm", Span.model.isnot(None))
|
||||
.group_by(Span.model).order_by(span_cost.desc()).limit(12)
|
||||
)).all()
|
||||
models = [
|
||||
{
|
||||
"model": r.model,
|
||||
"calls": int(r.calls or 0),
|
||||
"tokens": int(r.tokens or 0),
|
||||
"cost_usd": round(float(r.cost or 0.0), 6),
|
||||
"avg_latency_ms": int((r.latency_sum or 0) / r.calls) if r.calls else 0,
|
||||
}
|
||||
for r in model_rows
|
||||
]
|
||||
|
||||
# Latency distribution: one row, a conditional count per bucket (SQL-side, no row scan).
|
||||
hist_cols = []
|
||||
lo = 0
|
||||
for i, (_, hi) in enumerate(_LATENCY_BUCKETS):
|
||||
if hi is None:
|
||||
cond = Trace.latency_ms >= lo
|
||||
elif lo == 0:
|
||||
cond = Trace.latency_ms < hi
|
||||
else:
|
||||
cond = (Trace.latency_ms >= lo) & (Trace.latency_ms < hi)
|
||||
hist_cols.append(func.coalesce(func.sum(case((cond, 1), else_=0)), 0).label(f"b{i}"))
|
||||
lo = hi or lo
|
||||
hist_row = (await session.execute(select(*hist_cols).where(*scope, _ACTIVITY >= since))).one()
|
||||
latency_histogram = [
|
||||
{"label": label, "count": int(getattr(hist_row, f"b{i}") or 0)}
|
||||
for i, (label, _) in enumerate(_LATENCY_BUCKETS)
|
||||
]
|
||||
|
||||
# 8 most recent runs in the window (for the activity feed).
|
||||
recent_rows = (await session.execute(
|
||||
select(
|
||||
Trace.id, Trace.workflow_id, Trace.name, Trace.status,
|
||||
Trace.total_tokens, Trace.latency_ms, Trace.total_cost_usd, Trace.started_at,
|
||||
).where(*scope, _ACTIVITY >= since).order_by(Trace.started_at.desc()).limit(8)
|
||||
)).all()
|
||||
recent = [
|
||||
{
|
||||
"id": r.id,
|
||||
"workflow": wf_names.get(r.workflow_id or "", r.name or "run"),
|
||||
"project": "",
|
||||
"status": r.status,
|
||||
"tokens": r.total_tokens,
|
||||
"latency_ms": r.latency_ms,
|
||||
"cost_usd": round(r.total_cost_usd or 0.0, 6),
|
||||
"started_at": r.started_at.isoformat() if r.started_at else None,
|
||||
}
|
||||
for r in recent_rows
|
||||
]
|
||||
|
||||
return {
|
||||
"range": {"days": days, "since": since.isoformat(), "until": now.isoformat(), "bucket": "day"},
|
||||
"totals": totals,
|
||||
"prev_totals": prev_totals,
|
||||
"timeseries": timeseries,
|
||||
"by_source": by_source,
|
||||
"by_workflow": by_workflow,
|
||||
"tools": tools,
|
||||
"models": models,
|
||||
"latency_histogram": latency_histogram,
|
||||
"recent": recent,
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tool Set endpoints (CRUD + membership).
|
||||
|
||||
A tool set is a describable group of tools (see forge.services.tool_sets). Sets organize the
|
||||
Tools screen, can be granted to an agent as a unit, and are the unit published over MCP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
|
||||
from forge.models import ToolSet
|
||||
from forge.schemas.dto import ToolSetCreate, ToolSetOut, ToolSetUpdate
|
||||
from forge.services.tool_sets import ToolSetService
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/tool-sets", tags=["tool-sets"])
|
||||
|
||||
|
||||
def _to_out(ts: ToolSet, tool_ids: list[str]) -> ToolSetOut:
|
||||
return ToolSetOut(
|
||||
id=ts.id, project_id=ts.project_id, name=ts.name, slug=ts.slug,
|
||||
description=ts.description or "", icon=ts.icon, is_default=ts.is_default, exposed=ts.exposed, tool_ids=tool_ids,
|
||||
)
|
||||
|
||||
|
||||
async def _load(session: AsyncSession, tenant_id: str, project_id: str, set_id: str) -> ToolSet:
|
||||
ts = await ToolSetService.get(session, tenant_id, set_id)
|
||||
if ts is None or ts.project_id != project_id:
|
||||
raise HTTPException(404, "Tool set not found")
|
||||
return ts
|
||||
|
||||
|
||||
@router.get("", response_model=list[ToolSetOut])
|
||||
async def list_tool_sets(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
sets = await ToolSetService.list(session, tenant_id, project_id)
|
||||
members = await ToolSetService.members_map(session, tenant_id, project_id)
|
||||
return [_to_out(s, members.get(s.id, [])) for s in sets]
|
||||
|
||||
|
||||
@router.post("", response_model=ToolSetOut, status_code=201)
|
||||
async def create_tool_set(project_id: str, body: ToolSetCreate, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id), _: CurrentUser = Depends(require_role("editor"))):
|
||||
ts = await ToolSetService.create(
|
||||
session, tenant_id, project_id, name=body.name, description=body.description,
|
||||
icon=body.icon, is_default=body.is_default, exposed=body.exposed, tool_ids=body.tool_ids,
|
||||
)
|
||||
return _to_out(ts, await ToolSetService.member_ids(session, tenant_id, ts.id))
|
||||
|
||||
|
||||
@router.get("/{set_id}", response_model=ToolSetOut)
|
||||
async def get_tool_set(project_id: str, set_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
ts = await _load(session, tenant_id, project_id, set_id)
|
||||
return _to_out(ts, await ToolSetService.member_ids(session, tenant_id, ts.id))
|
||||
|
||||
|
||||
@router.patch("/{set_id}", response_model=ToolSetOut)
|
||||
async def update_tool_set(project_id: str, set_id: str, body: ToolSetUpdate, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id), _: CurrentUser = Depends(require_role("editor"))):
|
||||
ts = await _load(session, tenant_id, project_id, set_id)
|
||||
ts = await ToolSetService.update(
|
||||
session, ts, name=body.name, description=body.description, icon=body.icon,
|
||||
is_default=body.is_default, exposed=body.exposed, tool_ids=body.tool_ids,
|
||||
)
|
||||
return _to_out(ts, await ToolSetService.member_ids(session, tenant_id, ts.id))
|
||||
|
||||
|
||||
@router.delete("/{set_id}", status_code=204)
|
||||
async def delete_tool_set(project_id: str, set_id: str, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id), _: CurrentUser = Depends(require_role("editor"))):
|
||||
ts = await _load(session, tenant_id, project_id, set_id)
|
||||
await ToolSetService.delete(session, ts)
|
||||
|
||||
|
||||
@router.post("/{set_id}/tools/{tool_id}", status_code=204)
|
||||
async def add_tool_to_set(project_id: str, set_id: str, tool_id: str, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id), _: CurrentUser = Depends(require_role("editor"))):
|
||||
ts = await _load(session, tenant_id, project_id, set_id)
|
||||
await ToolSetService.add_member(session, ts, tool_id)
|
||||
|
||||
|
||||
@router.delete("/{set_id}/tools/{tool_id}", status_code=204)
|
||||
async def remove_tool_from_set(project_id: str, set_id: str, tool_id: str, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id), _: CurrentUser = Depends(require_role("editor"))):
|
||||
ts = await _load(session, tenant_id, project_id, set_id)
|
||||
await ToolSetService.remove_member(session, ts, tool_id)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tool endpoints (CRUD + /test)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import CurrentUser, current_tenant_id, get_session, require_role
|
||||
from forge.schemas.contracts import validate_against_id
|
||||
from forge.schemas.dto import (
|
||||
ExportIn,
|
||||
ImportIn,
|
||||
ImportReport,
|
||||
ToolCreate,
|
||||
ToolOut,
|
||||
ToolTestIn,
|
||||
ToolUpdate,
|
||||
)
|
||||
from forge.services.portability import PortabilityService
|
||||
from forge.services.tools import ToolService
|
||||
from forge.services.versions import safe_snapshot
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/tools", tags=["tools"])
|
||||
|
||||
|
||||
@router.post("/export")
|
||||
async def export_tools(project_id: str, body: ExportIn, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id)):
|
||||
"""Serialize the selected tools into a downloadable single-type bundle."""
|
||||
return await PortabilityService.export(session, tenant_id, project_id, "tool", body.ids)
|
||||
|
||||
|
||||
@router.post("/import", response_model=ImportReport)
|
||||
async def import_tools(project_id: str, body: ImportIn, session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id), user: CurrentUser = Depends(require_role("editor"))):
|
||||
"""Create tools from an uploaded bundle in THIS project (new ids, auto-renamed on collision)."""
|
||||
if body.type not in (None, "tool"):
|
||||
raise HTTPException(422, f"This file contains '{body.type}' exports — import it from the matching screen.")
|
||||
try:
|
||||
return await PortabilityService.import_bundle(session, tenant_id, project_id, body.model_dump(), author=user)
|
||||
except ValueError as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
|
||||
|
||||
@router.get("", response_model=list[ToolOut])
|
||||
async def list_tools(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
# Built-ins are project defaults: make sure this project has them before listing (idempotent),
|
||||
# so every project - including freshly imported ones - always has the platform capabilities.
|
||||
await ToolService.ensure_builtins(session, tenant_id, project_id)
|
||||
return await ToolService.list(session, tenant_id, project_id)
|
||||
|
||||
|
||||
@router.post("", response_model=ToolOut, status_code=201)
|
||||
async def create_tool(project_id: str, body: ToolCreate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
user: CurrentUser = Depends(require_role("editor"))):
|
||||
cfg = {**body.config, "name": body.name, "kind": body.kind}
|
||||
errors = validate_against_id(cfg, "forge/tool")
|
||||
if errors:
|
||||
raise HTTPException(422, detail={"errors": errors})
|
||||
tool = await ToolService.create(session, tenant_id, project_id, name=body.name, kind=body.kind, config=body.config, auth_provider_id=body.auth_provider_id)
|
||||
await safe_snapshot(session, "tool", tool, author=user)
|
||||
return tool
|
||||
|
||||
|
||||
@router.get("/{tool_id}", response_model=ToolOut)
|
||||
async def get_tool(project_id: str, tool_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
tool = await ToolService.get(session, tenant_id, tool_id)
|
||||
if tool is None:
|
||||
raise HTTPException(404, "Tool not found")
|
||||
return tool
|
||||
|
||||
|
||||
@router.patch("/{tool_id}", response_model=ToolOut)
|
||||
async def update_tool(project_id: str, tool_id: str, body: ToolUpdate, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
user: CurrentUser = Depends(require_role("editor"))):
|
||||
tool = await ToolService.get(session, tenant_id, tool_id)
|
||||
if tool is None:
|
||||
raise HTTPException(404, "Tool not found")
|
||||
if body.config is not None:
|
||||
cfg = {**body.config, "name": body.name or tool.name, "kind": tool.kind}
|
||||
errors = validate_against_id(cfg, "forge/tool")
|
||||
if errors:
|
||||
raise HTTPException(422, detail={"errors": errors})
|
||||
tool = await ToolService.update(session, tool, name=body.name, config=body.config, auth_provider_id=body.auth_provider_id, enabled=body.enabled)
|
||||
await safe_snapshot(session, "tool", tool, author=user)
|
||||
return tool
|
||||
|
||||
|
||||
@router.delete("/{tool_id}", status_code=204)
|
||||
async def delete_tool(project_id: str, tool_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
_: CurrentUser = Depends(require_role("editor"))):
|
||||
tool = await ToolService.get(session, tenant_id, tool_id)
|
||||
if tool is None:
|
||||
raise HTTPException(404, "Tool not found")
|
||||
if tool.kind == "builtin":
|
||||
raise HTTPException(409, "Built-in tools are platform capabilities and cannot be deleted. Disable it instead.")
|
||||
await ToolService.delete(session, tool)
|
||||
|
||||
|
||||
@router.post("/{tool_id}/test")
|
||||
async def test_tool(project_id: str, tool_id: str, body: ToolTestIn, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id),
|
||||
user: CurrentUser = Depends(require_role("editor"))):
|
||||
tool = await ToolService.get(session, tenant_id, tool_id)
|
||||
if tool is None:
|
||||
raise HTTPException(404, "Tool not found")
|
||||
cfg = {**(tool.config or {}), "name": tool.name, "kind": tool.kind, "auth_provider_id": tool.auth_provider_id or (tool.config or {}).get("auth_provider_id")}
|
||||
# Console tests run AS the current user, so a PER-USER auth provider resolves the tester's own
|
||||
# connected credential (end_user_id keys the per-user bundle - the same id the MCP PAT resolves
|
||||
# to). The run-context field can still override it.
|
||||
ctx = {"end_user_id": user.id, **(body.context or {})}
|
||||
result = await ToolService.test(tenant_id, project_id, cfg, body.args, ctx)
|
||||
await ToolService.record_test(session, tool, result)
|
||||
return result
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Trace endpoints - runs list + span detail."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.deps import current_tenant_id, get_session
|
||||
from forge.schemas.dto import TraceDetailOut, TraceOut
|
||||
from forge.services.traces import TraceService
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/traces", tags=["traces"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[TraceOut])
|
||||
async def list_traces(project_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
return await TraceService.list(session, tenant_id, project_id)
|
||||
|
||||
|
||||
@router.get("/{trace_id}", response_model=TraceDetailOut)
|
||||
async def get_trace(project_id: str, trace_id: str, session: AsyncSession = Depends(get_session), tenant_id: str = Depends(current_tenant_id)):
|
||||
trace = await TraceService.get(session, tenant_id, trace_id)
|
||||
if trace is None:
|
||||
raise HTTPException(404, "Trace not found")
|
||||
spans = await TraceService.spans(session, tenant_id, trace_id)
|
||||
return {"trace": trace, "spans": spans}
|
||||
@@ -0,0 +1,36 @@
|
||||
"""List a project's triggers (webhook URLs, schedules) for the console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from forge.config import settings
|
||||
from forge.deps import current_tenant_id, get_session
|
||||
from forge.models import Trigger
|
||||
|
||||
router = APIRouter(prefix="/v1/projects/{project_id}/triggers", tags=["triggers"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_triggers(
|
||||
project_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
tenant_id: str = Depends(current_tenant_id),
|
||||
):
|
||||
rows = (await session.execute(
|
||||
select(Trigger).where(Trigger.tenant_id == tenant_id, Trigger.project_id == project_id)
|
||||
)).scalars()
|
||||
base = settings.public_base_url.rstrip("/")
|
||||
out = []
|
||||
for t in rows:
|
||||
item = {
|
||||
"id": t.id, "workflow_id": t.workflow_id, "node_id": t.node_id, "kind": t.kind,
|
||||
"enabled": t.enabled, "config": t.config,
|
||||
"last_fired_at": t.last_fired_at.isoformat() if t.last_fired_at else None,
|
||||
}
|
||||
if t.kind == "webhook_in" and t.key:
|
||||
item["webhook_url"] = f"{base}/v1/hooks/{t.key}"
|
||||
out.append(item)
|
||||
return out
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user