Overview

FrameX logo

FrameX is a plugin-first Python framework for teams that need service decomposition, multi-team parallel development, private implementation boundaries, and one consistent service surface across local plugins and upstream APIs.

It is designed for services that are growing beyond a single cohesive code path and need clearer capability boundaries without forcing teams to build a custom platform first.

FrameX architecture

What Problem It Solves

FrameX is most useful when multiple teams need to ship capabilities in parallel, call each other through stable service interfaces, and keep implementation details private so each team can work without understanding or depending on other teams' codebases.

Use it when you need to:

  • build service capabilities as plug-and-play modules
  • let multiple engineers or teams ship in parallel with clearer ownership boundaries
  • split a growing service into independently evolving capability units
  • call other teams' capabilities without depending on their codebases
  • expose local plugins and upstream APIs behind one consistent service surface
  • integrate third-party or internal HTTP services with minimal client-side changes
  • start with simple local execution and scale to Ray when needed
  • keep the system extensible as capabilities, teams, and traffic grow

Core Concepts

FrameX is built around a few core ideas:

  • Plugin: a capability package with its own code, metadata, and API surface
  • @on_register(): registers a plugin class as a runtime unit
  • @on_request(...): exposes plugin methods as HTTP APIs, internal callable APIs, or both
  • required_remote_apis: declares which other plugin or HTTP APIs a plugin depends on
  • call_plugin_api(...): lets one capability call another through a stable service interface
  • @remote(): keeps the same call style across local execution and Ray execution
  • proxy plugin: makes upstream OpenAPI services look like part of the same service surface

Why FrameX Instead Of Plain FastAPI

Plain FastAPI is a good choice for a single cohesive application. FrameX is better when the real problem is not route handling, but service decomposition, team boundaries, and cross-service integration.

Compared with plain FastAPI, FrameX gives you:

  • plugin boundaries for clearer ownership between capabilities and teams
  • a better development model for plug-and-play modules and parallel delivery
  • one consistent surface for local capabilities and upstream HTTP services
  • internal callable APIs in addition to normal HTTP routes
  • explicit dependency declarations between capabilities
  • the ability to start locally and move to Ray-backed execution without rewriting plugin code

If you only need a small application with a stable route surface and one codebase, plain FastAPI is usually simpler.

Where It Fits Best

FrameX is a good fit when you want to:

  • build modular service capabilities as plugins
  • support multi-person or multi-team parallel development
  • reduce cross-team code familiarity requirements
  • expose local modules and upstream APIs behind one consistent service surface
  • start with a simple deployment model and scale execution later

Typical scenarios include:

  • multi-team service development with stable interfaces and private implementation boundaries
  • capability-oriented service splitting inside one growing service
  • transparent upstream HTTP integration through the same service boundary
  • gradual scaling from local execution to Ray-backed execution

Quick Start

This chapter shows the shortest path to running FrameX locally.

By the end, you will:

  • install FrameX
  • create one minimal plugin
  • start the service
  • call the plugin over HTTP
  • understand what to read next

Requirements

  • Python >=3.11

Install FrameX

Install the base package:

pip install framex-kit

If you plan to use Ray later, install the optional extra:

pip install "framex-kit[ray]"

Create a Minimal Plugin

Create a file named foo.py:

from typing import Any

from pydantic import BaseModel

from framex.consts import VERSION
from framex.plugin import BasePlugin, PluginMetadata, on_register, on_request

__plugin_meta__ = PluginMetadata(
    name="foo",
    version=VERSION,
    description="A minimal example plugin",
    author="you",
    url="https://github.com/touale/FrameX-kit",
)


class EchoBody(BaseModel):
    text: str


@on_register()
class FooPlugin(BasePlugin):
    def __init__(self, **kwargs: Any) -> None:
        super().__init__(**kwargs)

    @on_request("/foo", methods=["GET"])
    async def echo(self, message: str) -> str:
        return f"foo: {message}"

    @on_request("/foo_model", methods=["POST"])
    async def echo_model(self, model: EchoBody) -> dict[str, str]:
        return {"text": model.text}

This example exposes two plugin APIs:

  • GET /api/v1/foo
  • POST /api/v1/foo_model

FrameX discovers the plugin module, registers the plugin class, and mounts its APIs into one FastAPI service surface.

Run the Service

Start FrameX from the same directory:

PYTHONPATH=. framex run --load-plugins foo

Important:

  • --load-plugins is a repeatable option
  • it is not a comma-separated list
  • PYTHONPATH=. lets Python import the local foo.py module

You can also start with the built-in example plugin:

framex run --load-builtin-plugins echo

Call the API

Call the GET endpoint:

curl "http://127.0.0.1:8080/api/v1/foo?message=hello"

Expected response:

"foo: hello"

Call the POST endpoint:

curl -X POST "http://127.0.0.1:8080/api/v1/foo_model" \
  -H "Content-Type: application/json" \
  -d '{"text":"hello from post"}'

Expected response:

{"text":"hello from post"}

Open the API Docs

FrameX exposes standard FastAPI docs:

  • http://127.0.0.1:8080/docs
  • http://127.0.0.1:8080/redoc
  • http://127.0.0.1:8080/api/v1/openapi.json

What Just Happened

In this quick start, FrameX handled four things for you:

  • plugin discovery from the module you loaded
  • plugin registration through @on_register()
  • HTTP API exposure through @on_request(...)
  • unified service routing through one FastAPI ingress

That is the core development model: package a capability as a plugin, expose stable APIs, and let FrameX assemble them into one service.

Next Steps

Read the next chapters in this order:

  1. Basic Usage / Overview
  2. Project Structure
  3. Plugin Register & API Expose
  4. Cross-Plugin Access
  5. Plugin Loading & Startup

If you are evaluating FrameX for a larger system, focus on:

  • plugin boundaries
  • call_plugin_api(...)
  • proxy-based upstream integration
  • local vs Ray execution

Basic Usage Overview

This section covers the day-to-day programming model of FrameX.

The goal is to help you build service capabilities as plugins, expose them through a consistent API surface, and organize a codebase that can scale across people, teams, and execution modes.

What You Will Learn

After this section, you will understand how to:

  1. organize a FrameX project and place plugin modules clearly
  2. define plugin metadata and register runtime units with @on_register()
  3. expose HTTP APIs and internal callable APIs with @on_request(...)
  4. declare dependencies with required_remote_apis and call capabilities with call_plugin_api(...)
  5. configure plugins and runtime settings cleanly
  6. load plugins and start the service through CLI or configuration
  7. debug and test plugins in local, non-Ray mode

Section Roadmap

What This Section Is For

Use this section when you are:

  • building your first real plugin
  • turning a growing service into modular capabilities
  • standardizing how teams expose and consume service interfaces
  • preparing for later use of proxy mode or Ray without changing the basic development model

Project Structure

In practice, FrameX projects usually fall into two different structures.

You should choose the structure based on what you are publishing and how many plugins the project contains.

Structure 1: Single Plugin Package

Use this structure when the project itself is one plugin package.

This is a good fit when you want to:

  • build one reusable plugin
  • publish it to PyPI
  • let other FrameX projects load it directly by import path

Recommended layout:

your_plugin/
├── pyproject.toml
├── README.md
├── src/
│   └── your_plugin/
│       ├── __init__.py
│       ├── config.py
│       ├── models.py
│       └── service.py
└── tests/

In this structure, the plugin entry is usually defined in:

src/your_plugin/__init__.py

That means other projects can load it directly, for example:

framex run --load-plugins your_plugin

Or in config.toml:

load_plugins = ["your_plugin"]

When to use it

Choose this structure when:

  • the package contains one plugin
  • the plugin is intended to be reused across projects
  • you want the package name itself to be the plugin import path

Why it works

Because the whole package is the plugin, using src/your_plugin/__init__.py as the plugin entry is natural here.

The package is small, self-contained, and meant to be loaded as one unit.

Structure 2: Multi-Plugin Project

Use this structure when one project contains multiple plugins.

This is a good fit when you want to:

  • build several capabilities in one service
  • let different people or teams own different plugins
  • load only part of the project in different environments

Recommended layout:

your_project/
├── pyproject.toml
├── config.toml
├── README.md
├── src/
│   └── your_project/
│       ├── __init__.py
│       ├── __main__.py
│       ├── consts.py
│       └── plugins/
│           ├── __init__.py
│           ├── foo.py
│           └── bar/
│               ├── __init__.py
│               ├── config.py
│               └── service.py
└── tests/

In this structure, plugins usually live under:

src/your_project/plugins/

Typical import paths are:

  • your_project.plugins.foo
  • your_project.plugins.bar

Examples:

framex run --load-plugins your_project.plugins.foo
load_plugins = [
  "your_project.plugins.foo",
  "your_project.plugins.bar",
]

When to use it

Choose this structure when:

  • one project contains multiple plugins
  • different capabilities should stay clearly separated
  • you want cleaner ownership boundaries inside one codebase

Why it works

Because the project package and the plugin modules are different things.

  • your_project is the application package
  • your_project.plugins.* are the plugin packages or modules

That separation makes the codebase easier to understand and maintain.

Single File vs Package Plugin

Inside a multi-plugin project, each plugin can still be organized in two ways.

Single-file plugin

src/your_project/plugins/
└── foo.py

Use this when the plugin is still small.

Package plugin

src/your_project/plugins/
└── bar/
    ├── __init__.py
    ├── config.py
    ├── models.py
    └── service.py

Use this when the plugin is larger and needs multiple modules.

Built-In Plugins vs Your Plugins

Keep built-in plugins and your own plugins separate.

  • built-in plugins come from framex.plugins, such as echo and proxy
  • your own plugins come from your own package

Example:

framex run \
  --load-builtin-plugins echo \
  --load-plugins your_project.plugins.foo

Rule of Thumb

Use this simple rule:

  • if the package itself is one reusable plugin, put the plugin in src/your_plugin/__init__.py
  • if the project contains multiple plugins, create src/your_project/plugins/
  • if one plugin grows large, turn it from one file into one package

Plugin Register & API Expose

This chapter explains the core FrameX programming model:

  1. define plugin metadata
  2. register a plugin class
  3. expose APIs from plugin methods

If you understand these three pieces, you understand how a capability becomes part of a FrameX service.

Define Plugin Metadata

Each plugin module should define __plugin_meta__ with PluginMetadata(...).

Example:

from framex.consts import VERSION
from framex.plugin import PluginMetadata

__plugin_meta__ = PluginMetadata(
    name="foo",
    version=VERSION,
    description="A minimal example plugin",
    author="you",
    url="https://github.com/touale/FrameX-kit",
    required_remote_apis=["/api/v1/echo", "echo.EchoPlugin.confess"],
)

Important fields

  • name: plugin name
  • version: plugin version
  • description: short description of what the plugin does
  • author: maintainer or owning team
  • url: repo or documentation URL
  • required_remote_apis: APIs this plugin depends on

required_remote_apis can contain:

  • HTTP paths such as /api/v1/echo
  • function API names such as echo.EchoPlugin.confess

This metadata is used for plugin discovery, dependency resolution, and runtime registration.

Register the Plugin Class

A plugin class is registered with @on_register() and usually inherits from BasePlugin.

Example:

from typing import Any

from framex.plugin import BasePlugin, on_register


@on_register()
class FooPlugin(BasePlugin):
    def __init__(self, **kwargs: Any) -> None:
        super().__init__(**kwargs)

Lifecycle guidance

  • use __init__ for lightweight synchronous setup
  • use on_start for heavier or async initialization when needed

Example:

@on_register()
class FooPlugin(BasePlugin):
    def __init__(self, **kwargs: Any) -> None:
        super().__init__(**kwargs)

    async def on_start(self) -> None: ...

Expose APIs with @on_request(...)

Use @on_request(...) on plugin methods to expose them as callable APIs.

Typical modes are:

  • HTTP API: provide a route path
  • function API: use call_type=ApiType.FUNC
  • both: use call_type=ApiType.ALL

HTTP API example

@on_request("/echo", methods=["GET"])
async def echo(self, message: str) -> str:
    return message

A path like "/echo" is exposed through the normal FrameX HTTP surface, typically as /api/v1/echo.

HTTP API with request body

from pydantic import BaseModel


class EchoBody(BaseModel):
    text: str


@on_request("/echo_model", methods=["POST"])
async def echo_model(self, model: EchoBody) -> dict[str, str]:
    return {"text": model.text}

Function API example

from framex.plugin.model import ApiType


@on_request(call_type=ApiType.FUNC)
async def confess(self, message: str) -> str:
    return f"received: {message}"

This creates an internal callable API. A typical function API name looks like:

echo.EchoPlugin.confess

Expose both HTTP and function access

@on_request("/echo", methods=["GET"], call_type=ApiType.ALL)
async def echo(self, message: str) -> str:
    return message

Use this when the same capability should be reachable both as an HTTP route and as an internal callable API.

Important Implementation Notes

The current implementation has a few rules worth knowing:

  • a handler may declare at most one BaseModel parameter
  • stream=True creates a streaming endpoint
  • raw_response=True bypasses the default response wrapper

Streaming example

from collections.abc import AsyncGenerator


@on_request("/echo_stream", methods=["GET"], stream=True)
async def echo_stream(self, message: str) -> AsyncGenerator[str, None]:
    for chunk in [message, "done"]:
        yield chunk

Raw response example

@on_request("/healthz", methods=["GET"], raw_response=True)
async def healthz(self) -> dict[str, str]:
    return {"status": "ok"}

Without raw_response=True, normal non-streaming HTTP responses are wrapped by FrameX into the standard response envelope.

Minimal End-to-End Example

from typing import Any

from pydantic import BaseModel

from framex.consts import VERSION
from framex.plugin import BasePlugin, PluginMetadata, on_register, on_request
from framex.plugin.model import ApiType

__plugin_meta__ = PluginMetadata(
    name="foo",
    version=VERSION,
    description="A minimal example plugin",
    author="you",
    url="https://github.com/touale/FrameX-kit",
    required_remote_apis=[],
)


class EchoBody(BaseModel):
    text: str


@on_register()
class FooPlugin(BasePlugin):
    def __init__(self, **kwargs: Any) -> None:
        super().__init__(**kwargs)

    @on_request("/echo", methods=["GET"])
    async def echo(self, message: str) -> str:
        return message

    @on_request("/echo_model", methods=["POST"])
    async def echo_model(self, model: EchoBody) -> dict[str, str]:
        return {"text": model.text}

    @on_request(call_type=ApiType.FUNC)
    async def confess(self, message: str) -> str:
        return f"received: {message}"

This plugin exposes:

  • HTTP GET /api/v1/echo
  • HTTP POST /api/v1/echo_model
  • function API foo.FooPlugin.confess

Rule of Thumb

Keep this mental model:

  • PluginMetadata describes the capability
  • @on_register() makes the class a runtime plugin
  • @on_request(...) turns methods into FrameX APIs

That is the core contract for building plugins in FrameX.

Cross-Plugin Access

Cross-plugin access is how one FrameX capability calls another through a stable service interface.

This matters when:

  • one plugin composes another capability
  • multiple teams expose capabilities to each other
  • callers should depend on APIs, not on another team's codebase
  • part of the dependency graph may later move behind proxy mode

The model is simple:

  1. declare what your plugin depends on
  2. call those APIs through call_plugin_api(...)

Declare Dependencies with required_remote_apis

A plugin should declare the APIs it depends on in PluginMetadata.required_remote_apis.

Example:

__plugin_meta__ = PluginMetadata(
    name="invoker",
    version=VERSION,
    description="Calls APIs from another plugin",
    author="you",
    url="https://github.com/touale/FrameX-kit",
    required_remote_apis=[
        "/api/v1/echo",
        "/api/v1/echo_model",
        "/api/v1/echo_stream",
        "echo.EchoPlugin.confess",
    ],
)

required_remote_apis can contain:

  • HTTP paths such as /api/v1/echo
  • function API names such as echo.EchoPlugin.confess

This declaration gives FrameX a stable contract for dependency resolution.

Call Dependencies with call_plugin_api(...)

Use call_plugin_api(api_name, **kwargs) to call another registered API.

Example:

from framex import call_plugin_api

result = await call_plugin_api("/api/v1/echo", message="hello")

FrameX resolves the target from required_remote_apis. If proxy mode is enabled, unresolved HTTP paths can fall back to the built-in proxy plugin.

Plugin classes also have an internal convenience wrapper around the same mechanism, but call_plugin_api(...) is the public API this guide uses.

Which API Name Should You Use?

Use an HTTP path when the target capability is naturally exposed as a route:

  • /api/v1/echo
  • /api/v1/echo_model
  • /api/v1/echo_stream

Use a function API name when the target capability is internal-only and should not be exposed as a public HTTP route:

  • echo.EchoPlugin.confess

A simple rule:

  • use HTTP paths for normal service-facing capabilities
  • use function APIs for internal capability-to-capability calls

Common Calling Patterns

Call an HTTP API with simple parameters

Provider:

@on_request("/echo", methods=["GET"])
async def echo(self, message: str) -> str:
    return message

Caller:

from framex import call_plugin_api

result = await call_plugin_api("/api/v1/echo", message=message)

Call an HTTP API with a model payload

Provider:

class EchoBody(BaseModel):
    text: str


@on_request("/echo_model", methods=["POST"])
async def echo_model(self, model: EchoBody) -> dict[str, str]:
    return {"text": model.text}

Caller:

from framex import call_plugin_api

result = await call_plugin_api(
    "/api/v1/echo_model",
    model={"text": "hello"},
)

For model payloads, pass a dict.

Call a streaming API

Provider:

@on_request("/echo_stream", methods=["GET"], stream=True)
async def echo_stream(self, message: str) -> AsyncGenerator[str, None]: ...

Caller:

from framex import call_plugin_api

stream = await call_plugin_api("/api/v1/echo_stream", message=message)

Call a function API

Provider:

@on_request(call_type=ApiType.FUNC)
async def confess(self, message: str) -> str:
    return f"received: {message}"

Caller:

from framex import call_plugin_api

result = await call_plugin_api("echo.EchoPlugin.confess", message=message)

How to Discover Available APIs

The easiest places to inspect available APIs are:

  • startup logs
  • /docs
  • /redoc
  • /api/v1/openapi.json

Typical startup logs include entries like:

[SUCCESS] ... Found plugin HTTP API "/api/v1/echo" from plugin(echo)
[SUCCESS] ... Found plugin HTTP API "/api/v1/echo_stream" from plugin(echo)
[SUCCESS] ... Found plugin FUNC API "echo.EchoPlugin.confess" from plugin(echo)

Minimal End-to-End Example

from typing import Any

from framex import call_plugin_api
from framex.consts import VERSION
from framex.plugin import BasePlugin, PluginMetadata, on_register, on_request

__plugin_meta__ = PluginMetadata(
    name="invoker",
    version=VERSION,
    description="Calls APIs from another plugin",
    author="you",
    url="https://github.com/touale/FrameX-kit",
    required_remote_apis=[
        "/api/v1/echo",
        "echo.EchoPlugin.confess",
        "/api/v1/echo_model",
    ],
)


@on_register()
class InvokerPlugin(BasePlugin):
    def __init__(self, **kwargs: Any) -> None:
        super().__init__(**kwargs)

    @on_request("/evoke_echo", methods=["GET"])
    async def evoke(self, message: str) -> dict[str, Any]:
        echo = await call_plugin_api("/api/v1/echo", message=message)
        confess = await call_plugin_api("echo.EchoPlugin.confess", message=message)
        echo_model = await call_plugin_api(
            "/api/v1/echo_model",
            model={"text": message},
        )
        return {
            "echo": echo,
            "confess": confess,
            "echo_model": echo_model,
        }

Rule of Thumb

Keep this mental model:

  • required_remote_apis declares what your plugin depends on
  • call_plugin_api(...) is the public calling interface
  • HTTP paths are for service-facing APIs
  • function API names are for internal callable APIs

That keeps cross-plugin dependencies explicit, stable, and easier to evolve.

Plugin Configuration

FrameX configuration has two layers:

  1. runtime configuration for the whole service
  2. plugin-specific configuration under plugins.<plugin_name>

This chapter focuses on the settings you are most likely to use in real projects.

Where Configuration Comes From

FrameX loads settings from these sources:

  • environment variables
  • .env
  • .env.prod
  • config.toml
  • [tool.framex] in pyproject.toml

In the current implementation, environment variables take highest priority. config.toml and [tool.framex] are useful project-level defaults.

CLI options are then applied before startup, so flags such as --port, --load-plugins, and --load-builtin-plugins can override configuration at runtime.

Minimal config.toml

For most projects, config.toml is the clearest place to start.

Example:

load_builtin_plugins = ["echo"]
load_plugins = ["your_project.plugins.foo"]

[server]
host = "127.0.0.1"
port = 8080
use_ray = false
enable_proxy = false

[plugins.foo]
debug = true

The most common top-level fields are:

  • load_builtin_plugins
  • load_plugins
  • server
  • plugins
  • auth

Most Common Runtime Settings

In normal usage, the most important settings are:

  • server.host
  • server.port
  • server.use_ray
  • server.enable_proxy
  • load_builtin_plugins
  • load_plugins
  • plugins.<plugin_name>
  • auth.rules

You do not need to understand every field in the global Settings model before using FrameX productively. Most projects only touch a small subset.

Plugin-Specific Configuration

Plugin-specific settings live under:

[plugins.<plugin_name>]

Example:

[plugins.foo]
debug = true
timeout = 30

This keeps plugin settings separate from global runtime settings.

Typed Plugin Configuration

If a plugin wants typed configuration, it can declare a Pydantic model and attach it through config_class in PluginMetadata.

Define a config model

from pydantic import BaseModel


class FooConfig(BaseModel):
    debug: bool = False
    timeout: int = 30

Attach it to plugin metadata

__plugin_meta__ = PluginMetadata(
    name="foo",
    version=VERSION,
    description="A minimal example plugin",
    author="you",
    url="https://github.com/touale/FrameX-kit",
    required_remote_apis=[],
    config_class=FooConfig,
)

Read it inside the plugin

from framex.plugin import get_plugin_config

settings = get_plugin_config("foo", FooConfig)

If no config is provided for that plugin, FrameX returns the config model with its default values and logs a warning.

Example: Plugin Config in config.toml

If the plugin is named foo, the matching config block looks like this:

[plugins.foo]
debug = true
timeout = 30

That block is what get_plugin_config("foo", FooConfig) reads.

Environment Variables

Nested settings can also be provided through environment variables with __ as the separator.

Examples:

export SERVER__PORT=9000
export SERVER__ENABLE_PROXY=true
export PLUGINS__FOO__DEBUG=true

Because the current settings model uses case_sensitive=False, you do not need to rely on lowercase-only keys.

When to Use Which Format

Use config.toml when:

  • you want readable project defaults
  • the configuration is hierarchical
  • you want plugin settings grouped clearly in version control

Use environment variables when:

  • deployment environments need different overrides
  • secrets or environment-specific values should not live in the repo
  • CI, containers, or runtime platforms inject settings dynamically

A practical pattern is:

  • keep stable defaults in config.toml
  • use environment variables for deployment-specific overrides

Rule of Thumb

Keep this mental model:

  • server.* configures the runtime
  • load_plugins and load_builtin_plugins control what gets loaded
  • plugins.<plugin_name> configures one plugin
  • config_class + get_plugin_config(...) gives that plugin typed settings

That is enough for most real FrameX projects.

Plugin Loading & Startup

This chapter explains how FrameX loads plugins and starts the runtime.

Loading Methods

FrameX supports two loading methods:

  1. load plugins in Python code
  2. declare plugins in configuration

In both cases, startup ends with framex.run().

Load Plugins in Python Code

Use load_builtin_plugins(...) for built-in plugins and load_plugins(...) for your own plugins.

import framex


def main() -> None:
    framex.load_builtin_plugins("echo")
    framex.load_plugins("your_project.plugins.foo")
    framex.run()


if __name__ == "__main__":
    main()

Multiple Plugins

import framex


def main() -> None:
    framex.load_builtin_plugins("echo", "proxy")
    framex.load_plugins(
        "your_project.plugins.foo",
        "your_project.plugins.bar",
    )
    framex.run()

Package Path

You can also load a package path:

framex.load_plugins("your_project.plugins")

Use a module path when you want one plugin:

  • your_project.plugins.foo

Use a package path when you want FrameX to search under that package:

  • your_project.plugins

Every loaded plugin module or package must define:

__plugin_meta__ = PluginMetadata(...)

Load Plugins from Configuration

You can declare the startup plugin list in config.toml:

load_builtin_plugins = ["echo", "proxy"]
load_plugins = [
  "your_project.plugins.foo",
  "your_project.plugins.bar",
]

Then start FrameX normally:

import framex

framex.run()

This is useful when different environments need different plugin sets without changing code.

Load Plugins from CLI

The CLI exposes the same loading model.

framex run --load-builtin-plugins echo --load-plugins your_project.plugins.foo

Both options are repeatable:

framex run \
  --load-builtin-plugins echo \
  --load-builtin-plugins proxy \
  --load-plugins your_project.plugins.foo \
  --load-plugins your_project.plugins.bar

Do not pass them as comma-separated strings.

Built-In Plugins vs Your Plugins

  • load_builtin_plugins(...) loads built-in plugins such as echo and proxy
  • load_plugins(...) loads your own plugin import paths

Example:

framex.load_builtin_plugins("proxy")
framex.load_plugins("your_project.plugins.foo")

Startup Order

The normal startup sequence is:

  1. load built-in plugins if needed
  2. load your own plugins
  3. call framex.run()

If you are using configuration-based loading, framex.run() reads the configured plugin lists and starts the runtime.

Rule of Thumb

Use this simple rule:

  • one plugin module: your_project.plugins.foo
  • multiple plugin modules: repeat load_plugins(...)
  • one plugin package tree: your_project.plugins
  • built-in plugin: load_builtin_plugins(...)

Keep plugin paths explicit and stable. That makes startup behavior easier to understand and debug.

Plugin Debugging & Testing

This chapter covers two practical tasks:

  1. debugging plugins locally
  2. testing plugins with pytest

Debug Plugins Locally

For local debugging, keep Ray disabled.

config.toml:

[server]
use_ray = false

In this mode, FrameX runs as a normal FastAPI application, so you can debug it like any other Python web service.

Keep Ray Off During Normal Debugging

If you are still changing plugin logic, keep use_ray = false until the behavior is stable.

Use Ray only when you specifically need to debug distributed execution behavior.

Build a Test App

For tests, start FrameX in test mode and return the FastAPI app.

import pytest
from fastapi import FastAPI

import framex


@pytest.fixture(scope="session")
def test_app() -> FastAPI:
    framex.load_builtin_plugins("echo")
    framex.load_plugins("your_project.plugins.foo")
    return framex.run(test_mode=True)  # type: ignore[return-value]

Use TestClient

Wrap the test app with TestClient:

from typing import Generator

import pytest
from fastapi.testclient import TestClient


@pytest.fixture(scope="session")
def client(test_app) -> Generator[TestClient, None, None]:
    with TestClient(test_app) as test_client:
        yield test_client

Example HTTP Test

from fastapi.testclient import TestClient


def test_echo(client: TestClient) -> None:
    response = client.get("/api/v1/echo", params={"message": "hello"})
    assert response.status_code == 200
    assert response.json()["data"] == "hello"

Test Streaming APIs

If a plugin exposes a streaming endpoint, test it with client.stream(...).

def test_stream(client: TestClient) -> None:
    with client.stream(
        "GET", "/api/v1/echo_stream", params={"message": "hello"}
    ) as response:
        assert response.status_code == 200

Rule of Thumb

Use this simple workflow:

  • debug in local mode with Ray off
  • test through HTTP with framex.run(test_mode=True)
  • turn Ray on only for dedicated integration coverage

Advanced Usage

This section covers the parts of FrameX that matter once basic plugin registration is already working.

Use it when your service needs upstream API integration, distributed execution, tighter access control, or more operational tuning than the basic usage section provides.

Topics

When To Read This Section

  • after you can register and load basic plugins
  • when plugins need to call each other through stable interfaces
  • when upstream HTTP services should look like part of the same application
  • when local execution is no longer enough and you want to move selected workloads to Ray
  • when the service is growing and needs clearer operational boundaries

If you are still learning the basic runtime model, finish the basic usage section first.

System Proxy Plugin

The built-in proxy plugin has two core roles in FrameX, plus one more advanced extension point.

Core Role 1: Add Upstream Services To Your Own API Surface

If you already have an upstream FastAPI service or another FrameX service, the proxy plugin can pull that service into the current FrameX API surface.

That means callers can keep using the current FrameX service, while some capabilities are actually forwarded to another service behind the scenes.

Typical uses:

  • keep an existing FastAPI service while introducing FrameX gradually
  • aggregate multiple services behind one FrameX boundary
  • expose upstream APIs through the same /api/v1/... style surface as local plugins

Minimal Config

load_builtin_plugins = ["proxy"]

[server]
enable_proxy = true

[plugins.proxy]
white_list = ["/*"]
force_stream_apis = ["/api/v1/chat/stream"]
timeout = 600

[plugins.proxy.proxy_urls."http://127.0.0.1:9000"]
enable = ["/api/v1/*"]
disable = []

This proxy_urls mapping is the current full config form in the codebase.

The older list form still exists in the type definition, but if you want per-upstream control, use the dict form shown above.

How It Works

At startup, the proxy plugin reads the upstream /api/v1/openapi.json document, filters routes through the configured allow rules, and registers matching forwarding routes locally.

The current implementation supports:

  • query parameters
  • JSON request bodies
  • multipart/form-data
  • file uploads
  • explicitly marked streaming APIs

Core Role 2: Preserve Privacy And Isolation In Multi-Plugin Collaboration

The second role is more important in larger systems.

In a multi-plugin or multi-team setup, one team may need to call another team's capability without having that plugin code locally.

In that case, the caller only depends on the API contract, not on the other team's implementation.

This gives you two benefits:

  • implementation privacy: the upstream plugin code does not need to be shared locally
  • codebase isolation: one team does not need to understand or import another team's plugin package

What This Looks Like

A plugin declares the API it depends on:

__plugin_meta__ = PluginMetadata(
    name="invoker",
    version=VERSION,
    description="Calls another capability through a stable API",
    author="you",
    url="https://github.com/touale/FrameX-kit",
    required_remote_apis=["/api/v1/base/version"],
)

Then it calls that API through the normal public interface:

from framex import call_plugin_api

version = await call_plugin_api("/api/v1/base/version")

If that API exists locally, FrameX uses the local plugin.

If it does not exist locally and proxy mode is enabled, the HTTP path can fall back to the proxy plugin and be forwarded to an upstream service.

That is the key point: the calling side does not need to change just because the real implementation lives somewhere else.

Proxy URL Rules

The proxy plugin supports two rule levels:

  • global rules through white_list
  • per-upstream rules through proxy_urls.<base_url>.enable and proxy_urls.<base_url>.disable

Example:

[plugins.proxy]
white_list = ["/api/v1/*"]

[plugins.proxy.proxy_urls."http://127.0.0.1:9000"]
enable = ["/api/v1/*"]
disable = ["/api/v1/internal/*"]

Rule order in the code is:

  1. per-URL disable
  2. per-URL enable
  3. global white_list

Streaming APIs

If an upstream route should stay on the streaming path, add it to force_stream_apis:

[plugins.proxy]
force_stream_apis = ["/api/v1/chat/stream"]

Those paths stay on the streaming code path instead of being handled as normal JSON responses.

Authentication

If the upstream service is another FrameX service and it has enabled auth.rules, you need to configure matching keys in plugins.proxy.auth.rules so the proxy plugin can call it successfully.

The proxy plugin uses these rules in two places:

  • when fetching the upstream /api/v1/openapi.json
  • when forwarding requests to protected upstream API paths

Example:

[plugins.proxy.auth]
rules = {
  "/api/v1/openapi.json" = ["docs-key"],
  "/api/v1/base/version" = ["service-key"],
  "/api/v1/base/*" = ["service-key"]
}

That means:

  • the proxy plugin uses docs-key to read the upstream OpenAPI document
  • it uses service-key when forwarding protected API calls to /api/v1/base/version or /api/v1/base/*

So if a cloud FrameX service protects its routes with auth.rules, the local service must mirror the needed upstream path rules in plugins.proxy.auth.rules and provide the corresponding keys.

For the full auth model and rule behavior on the upstream service itself, see Security & Authorization.

Advanced Use Cases

The proxy plugin also supports more advanced usage patterns beyond plain HTTP route forwarding.

One of them is function-style proxying and remote invocation. That topic is covered separately in Proxy Function & Remote Invocation.

Rule Of Thumb

Use the proxy plugin when you need one of these outcomes:

  • expose an upstream FastAPI or FrameX service through your current FrameX API surface
  • let one plugin call another team's capability without importing or shipping that plugin locally
  • keep API names stable while the real implementation moves between local and remote services

Integrating Ray Engine

Ray is the optional distributed execution backend in FrameX.

Its role is to move the runtime from local process execution to Ray-backed execution, while keeping the same plugin model and API surface.

Core Role

When Ray is enabled, FrameX keeps the same plugin structure but changes the execution backend.

That means:

  • plugin deployments run through Ray Serve
  • the main FastAPI ingress is mounted through Ray Serve
  • plugin code does not need to be rewritten just because the backend changes

In other words, Ray changes how the system runs, not how plugins are written.

Install Ray Support

Ray support is optional:

pip install "framex-kit[ray]"

If Ray is not installed and you enable use_ray = true, startup fails.

Enable Ray

Turn it on in configuration:

[server]
use_ray = true
dashboard_host = "127.0.0.1"
dashboard_port = 8260

You can also set the same values through CLI options or environment variables.

What Changes After Enabling Ray

When server.use_ray = true:

  • FrameX switches from the local adapter to RayAdapter
  • plugin deployments become Ray Serve deployments
  • the main ingress is mounted through Ray Serve

What does not change:

  • PluginMetadata
  • @on_register()
  • @on_request(...)
  • call_plugin_api(...)
  • plugin import paths and loading model

Example Config

load_builtin_plugins = ["echo"]
load_plugins = ["your_project.plugins.foo"]

[server]
host = "127.0.0.1"
port = 8080
use_ray = true
dashboard_host = "127.0.0.1"
dashboard_port = 8260

Ray Dashboard

When Ray is enabled, the dashboard is available at the configured dashboard host and port.

Example:

[server]
dashboard_host = "127.0.0.1"
dashboard_port = 8260

Constraints

If your codebase uses @remote(), enabling Ray changes how those calls execute at runtime. The detailed behavior is covered in Advanced Remote Calls & Non-Blocking Execution.

Rule Of Thumb

Use Ray when you need the same plugin model with a different execution backend.

Keep local mode when you are developing, debugging, or running lightweight tests.

Advanced Remote Calls & Non-Blocking Execution

The @remote() decorator has one core role in FrameX: move execution-heavy work behind a separate execution boundary while keeping the call site stable.

Core Role

A plugin route can be lightweight at the API layer, while the work behind it is not.

That work may involve:

  • blocking synchronous libraries
  • long-running computation
  • legacy code that is not fully async
  • logic that should keep the same call form in local mode and Ray mode

If that work stays inline inside the plugin handler, the plugin becomes harder to isolate and harder to move across execution backends later.

Even though enabling Ray ensures that one plugin's heavy workload will not block other plugins, it does not prevent blocking inside a single plugin itself.

If some part of your plugin code performs a blocking operation, such as time.sleep, long-running computation, or a non-async library call, the entire plugin instance may become unresponsive.

@remote() gives that work a stable execution boundary without changing the surrounding plugin API.

If you are defining a plugin API, use @on_request(...). If you are defining execution work behind that API, use @remote().

Typical Uses

Use @remote() when you want one of these outcomes:

  • keep blocking or heavy work out of the main plugin handler path
  • keep the same call form in both local mode and Ray mode
  • isolate execution-heavy logic without changing the plugin API surface
  • prepare code that runs locally today but may run through Ray later

What This Looks Like

A plugin handler stays focused on request handling:

from framex.plugin import on_request, remote


@remote()
def heavy_job(x: int) -> int:
    return x * 2


@on_request("/api/v1/demo/run")
async def run_job(x: int):
    return await heavy_job.remote(x)

That is the main pattern: keep the API-facing handler small, and move execution-heavy work behind @remote().

Supported Shapes

The current implementation supports:

  • plain functions
  • instance methods
  • class methods

The stable call form is:

await func.remote(...)

Execution Behavior

The same .remote(...) call uses different execution paths depending on the active adapter.

In local mode:

  • async functions are awaited directly
  • sync functions run through asyncio.to_thread(...)

In Ray mode:

  • the callable is wrapped through ray.remote(...)
  • .remote(...) executes through Ray

That is the key point: the call interface stays stable while the backend changes.

Ray backend setup is covered in Integrating Ray Engine.

More Examples

Plain Function

from framex.plugin import remote


@remote()
def heavy_job(x: int) -> int:
    return x * 2


result = await heavy_job.remote(21)

Instance Method

from framex.plugin import remote


class Worker:
    @remote()
    def total(self, values: list[int]) -> int:
        return sum(values)


count = await Worker().total.remote([1, 2, 3])

Class Method

from framex.plugin import remote


class Worker:
    @classmethod
    @remote()
    def scale(cls, x: int) -> int:
        return x * 10


value = await Worker.scale.remote(2)

Rule Of Thumb

Use @remote() when you need one callable interface with backend-dependent execution.

Use @on_request(...) for APIs. Use @remote() for the work those APIs call behind the scenes.

Security & Authorization

This chapter shows how to configure authentication in FrameX.

In practice, there are three common cases:

  • protect your own API routes
  • protect /docs and /api/v1/openapi.json with OAuth login
  • let the built-in proxy plugin call a protected upstream service

Protect API Routes

Use auth.rules when you want specific API paths to require authentication.

[auth]
rules = {
  "/api/v1/base/version" = ["version-key"],
  "/api/v1/base/*" = ["service-key"]
}

With this config:

  • /api/v1/base/version accepts version-key
  • other routes under /api/v1/base/* accept service-key
  • routes that do not match any rule stay public

Callers should send the key through the Authorization header.

curl -H 'Authorization: service-key' http://127.0.0.1:8080/api/v1/base/hello

The current implementation matches the header value directly. It does not add a Bearer prefix automatically.

If you use both an exact rule and a wildcard rule, the exact rule wins. Among wildcard rules, the longest matching prefix wins.

Protect Docs And OpenAPI With OAuth

Use auth.oauth when you want these routes to go through an OAuth login flow:

  • /docs
  • /redoc
  • /api/v1/openapi.json

Minimal config:

[auth]
rules = {
  "/docs" = ["docs-key"],
  "/redoc" = ["docs-key"],
  "/api/v1/openapi.json" = ["docs-key"]
}

[auth.oauth]
base_url = "https://gitlab.example.com"
client_id = "your-client-id"
client_secret = "your-client-secret"
redirect_uri = "/oauth/callback"
app_url = "http://127.0.0.1:8080"

With this config:

  • FrameX mounts the callback route at redirect_uri
  • app_url + redirect_uri becomes the callback URL sent to the OAuth provider
  • visiting /docs without a valid login redirects the user to the provider login page
  • after login, FrameX stores a framex_token cookie and redirects back to /docs

If you need to override provider endpoints directly, you can also set:

  • authorization_url
  • token_url
  • user_info_url

If auth.oauth is not configured, this OAuth login flow is disabled.

Let Proxy Call A Protected Upstream Service

If you use the built-in proxy plugin to connect to another protected service, configure auth under plugins.proxy.auth.rules.

load_builtin_plugins = ["proxy"]

[server]
enable_proxy = true

[plugins.proxy]
white_list = ["/api/v1/*"]

[plugins.proxy.proxy_urls."http://127.0.0.1:9000"]
enable = ["/api/v1/*"]
disable = []

[plugins.proxy.auth]
rules = {
  "/api/v1/openapi.json" = ["docs-key"],
  "/api/v1/base/version" = ["service-key"],
  "/api/v1/base/*" = ["service-key"]
}

Use this when the upstream service itself is protected and the local proxy still needs to:

  • fetch the upstream /api/v1/openapi.json
  • forward protected upstream API requests
  • call the proxy-function endpoint used by advanced proxy invocation

One important detail from the current implementation: the proxy plugin uses only the first matched key for each protected path.

So if you configure multiple keys in one rule, the first one is the key that will actually be forwarded upstream.

For proxy-specific usage patterns, see System Proxy Plugin (Fastapi API Compatibility).

Rule Of Thumb

Use auth.rules to protect your own API routes.

Use auth.oauth when you want browser access to /docs, /redoc, or /api/v1/openapi.json to go through OAuth login.

Use plugins.proxy.auth.rules when your local service needs to call a protected upstream service through the built-in proxy plugin.

Concurrency & Ingress Configuration

This chapter explains where ingress and concurrency settings are configured in FrameX.

The important part is that FrameX has more than one config path, and they do not all affect the same deployment.

What To Configure

In the current codebase, ingress-related settings are configured in three places:

  • base_ingress_config
  • server.ingress_config
  • plugin-level kwargs passed to @on_register(...)

These settings are forwarded through the adapter layer.

In practice, the most common field is max_ongoing_requests.

base_ingress_config: Global Default

base_ingress_config is the global default used when FrameX builds deployments.

base_ingress_config = { max_ongoing_requests = 10 }

This value is the starting point for both:

  • the main API ingress
  • plugin deployments

server.ingress_config: Main API Ingress Override

server.ingress_config applies only to the main APIIngress deployment.

If you leave it empty, FrameX now computes a default automatically from the number of loaded HTTP deployments.

The current rule is:

  • start from base_ingress_config.max_ongoing_requests
  • multiply it by the number of loaded HTTP deployments
  • keep a floor of base * 6

So with:

base_ingress_config = { max_ongoing_requests = 10 }

and about 30 loaded plugin deployments, the main API ingress default becomes 300.

If you want to override that behavior explicitly, set server.ingress_config yourself:

[server]
ingress_config = { max_ongoing_requests = 120 }

That explicit value wins over the adaptive default.

Plugin Ingress Settings

If a plugin needs its own ingress settings, pass them directly into @on_register(...).

from framex.plugin import BasePlugin, on_register


@on_register(max_ongoing_requests=20)
class MyPlugin(BasePlugin):
    pass

The built-in proxy plugin uses exactly this pattern, but its values come from plugin config:

[plugins.proxy]
ingress_config = { max_ongoing_requests = 60 }

And then inside the plugin:

@on_register(**settings.ingress_config)
class ProxyPlugin(BasePlugin): ...

So if you want per-plugin concurrency control, configure it at the plugin level.

Using It With Ray

If you run FrameX with server.use_ray = true, ingress settings become worth tuning.

This is the mode where values like max_ongoing_requests matter most for the main API ingress and for plugin deployments.

A practical starting point is:

  • leave server.ingress_config empty and let FrameX size the main API ingress automatically
  • only add plugin-level ingress settings for plugins that are clearly hotter or heavier than the rest
  • adjust num_cpus separately if the whole Ray runtime needs more CPU capacity
[server]
use_ray = true
num_cpus = 4

If you are still developing locally and not pushing concurrency yet, you usually do not need to tune these values first.

Using It In Local Mode

In local development, keep this simple.

Start with the defaults, and only add ingress settings after you have a concrete reason such as a hot plugin or a busy Ray deployment target later.

For most local debugging and feature work, these settings are not the first thing to optimize.

Rule Of Thumb

Use base_ingress_config for global defaults.

Leave server.ingress_config empty if you want FrameX to size the main API ingress automatically.

Set server.ingress_config explicitly when you want a fixed ingress limit for the whole service.

Use plugin-level @on_register(...) kwargs, or plugin config forwarded into @on_register(...), when one plugin needs different ingress behavior from the rest of the service.

Proxy Function & Remote Invocation

This feature is for one specific case: a function may run locally in one deployment, but must run on another FrameX service in another deployment.

FrameX lets you keep the same function call in code and decide through configuration whether that function runs locally or remotely.

When To Use It

Use proxy functions when a capability cannot or should not run in the local service.

Typical scenarios:

  • the local service does not have MySQL or Redis access, but another FrameX service does
  • the local service is restricted to outbound HTTP only, while another FrameX service can access internal networks
  • sensitive logic should stay on the team-owned service instead of being copied into another codebase
  • one team wants to call another team's internal capability without importing that implementation locally

In these cases, proxy functions let you keep the local call path stable while moving the real execution to another FrameX instance.

How It Differs From @on_request(...)

Use @on_request(...) when you want to expose an API route.

Use @on_proxy() when you want a function call to stay internal, but be able to run either:

  • locally
  • or on a remote FrameX service

The key difference is that proxy functions are configuration-driven.

The same function can:

  • run locally if it is not listed in plugins.proxy.proxy_functions
  • run remotely if it is listed there

That switch does not require changing the call site.

Minimal Example

from typing import Any

from framex.plugin import BasePlugin, on_proxy, on_register, register_proxy_func


@on_proxy()
async def build_report(job_id: str) -> dict[str, Any]:
    return {"job_id": job_id, "status": "done"}


@on_register()
class ReportPlugin(BasePlugin):
    async def on_start(self) -> None:
        await register_proxy_func(build_report)

How To Configure It

Enable the built-in proxy plugin and declare the remote function under plugins.proxy.proxy_functions.

load_builtin_plugins = ["proxy"]

[server]
enable_proxy = true

[plugins.proxy.proxy_urls."http://remote-framex:8080"]
enable = ["/api/v1/*"]
disable = []

[plugins.proxy]
proxy_functions = { "http://remote-framex:8080" = ["your_module.build_report"] }

With this config:

  • build_report(...) stays the same in code
  • if the function name is listed in proxy_functions, FrameX forwards it to the remote service
  • if it is not listed there, the function runs locally

Requirements

  • @on_proxy() only supports async functions
  • proxy-function calls only support keyword arguments
  • server.enable_proxy = true and load_builtin_plugins = ["proxy"] must both be set
  • every URL used in proxy_functions must also exist in proxy_urls
  • the remote service must also register the same proxy function during startup

Protected Remote Services

If the remote FrameX service protects the proxy-function endpoint, add an auth rule for /api/v1/proxy/remote under plugins.proxy.auth.rules.

[plugins.proxy.auth]
rules = {
  "/api/v1/proxy/remote" = ["proxy-key"]
}

For the full auth model, see Security & Authorization.

Rule Of Thumb

Use @on_request(...) for public API routes.

Use @on_proxy() when you want the same internal function call to be able to run locally or on another FrameX service, depending on configuration.

Simulating Network Communication (For Tests)

This feature is designed exclusively for tests.

When writing unit tests or integration tests that involve HTTP calls, you should avoid calling real external services.

Instead, use pytest-vcr to record the first HTTP interaction and replay it in subsequent test runs.

Why Use It

  • makes tests deterministic instead of depending on unstable external APIs
  • makes tests faster by avoiding repeated network requests
  • prevents accidental consumption of API quota or credentials
  • keeps CI runs consistent without requiring real upstream services

VCR Configuration

Put these fixtures in tests/conftest.py:

import pytest

from framex.config import settings


@pytest.fixture(scope="module")
def vcr_config():
    return {
        "record_mode": "new_episodes",  # record new calls only when missing
        "filter_headers": ["authorization", "api-key"],  # hide secrets
        "ignore_hosts": ["testserver"],  # don't record FastAPI TestClient
        "match_on": ["uri", "method", "body", "path", "query"],
        "allow_playback_repeats": True,
    }


@pytest.fixture(scope="module")
def disable_recording(request):  # noqa
    return settings.test.disable_record_request


def before_record_request(request):
    # Skip recording noisy or irrelevant endpoints
    if all(ch not in request.path for ch in ["rerank", "minio"]):
        return request
    return None


def before_record_response(response):
    if response["status"]["code"] != 200:
        return None
    return response

Writing A Test

Then annotate the test with @pytest.mark.vcr(...):

import pytest
from fastapi.testclient import TestClient

from framex.consts import API_STR
from tests.conftest import before_record_request, before_record_response


@pytest.mark.vcr(
    before_record_request=before_record_request,
    before_record_response=before_record_response,
)
def test_get_proxy_version(client: TestClient):
    res = client.get(f"{API_STR}/base/version").json()
    assert res["status"] == 200
    assert res["data"]

On the first run, VCR records the HTTP interaction.

On later runs, the same response is replayed from the cassette, so the test does not need to call the real upstream service again.

Rule Of Thumb

Use pytest-vcr when a FrameX test depends on HTTP behavior and you want to keep the request path realistic while making repeated runs deterministic.

Monitoring & Tracing

FrameX supports two practical monitoring paths today: Sentry for application errors and traces, and Ray Dashboard for distributed execution visibility.

Sentry

Sentry is enabled through the sentry config block.

[sentry]
enable = true
dsn = "<your-sentry-dsn>"
env = "local"
debug = true
ignore_errors = []
lifecycle = "trace"
enable_logs = true

What Sentry Covers

  • application errors and exceptions
  • trace collection when lifecycle = "trace"
  • optional log capture with enable_logs = true
  • custom ignore rules through ignore_errors

FrameX only initializes Sentry when enable, dsn, and env are all set.

Ray Dashboard

Ray Dashboard is available when the service runs with use_ray = true.

[server]
use_ray = true
dashboard_host = "127.0.0.1"
dashboard_port = 8260

When Ray starts, the dashboard is available at the configured host and port.

Log Noise Control

FrameX also keeps routine logs readable through the log and server config blocks.

[log]
simple_log = true
ignored_contains = ["GET /ping", "GET /health"]

[server]
excluded_log_paths = ["/api/v1/openapi.json"]

This helps keep health checks, docs traffic, and Ray noise out of the main request logs.

Notes

  • Sentry environment names are combined with the adapter mode internally, so local and Ray runs are separated.
  • The OpenAPI page includes a small runtime status block with uptime and version information.
  • FrameX does not currently ship a separate metrics backend beyond Sentry and Ray Dashboard.

Development Guide

This guide is for contributors who want to work on FrameX locally and keep changes aligned with the current README and book chapters.

Getting Started

Create a local environment and install the project in editable mode:

uv sync --dev

If you want to verify the runtime quickly, start the built-in echo plugin:

framex run --load-builtin-plugins echo
  1. Make your change in a feature branch.
  2. Keep code, examples, and docs consistent with the public API.
  3. Run the relevant tests before opening a pull request.
  4. Re-run formatting if a hook changes files.

The commands you will use most often are:

poe test-all

Dependency Management

Use uv to manage dependencies:

uv add requests
uv add pytest --group dev
uv remove requests
uv lock --upgrade-package requests

After dependency changes, sync the environment again:

uv sync --dev

Project Tasks

poe exposes the main project tasks:

poe help

Common tasks include:

  • style: format and fix style issues
  • lint: run style and type checks
  • test: run the fast test set
  • test-all: run the full test suite
  • build: build source and wheel packages
  • publish: publish the package to PyPI

Commit Style

This project uses short, conventional commit messages:

<type>: <message>

Common types are feat, fix, docs, refactor, test, build, and chore.

Examples:

feat: add plugin config loading example
fix: handle proxy plugin response errors
docs: update quickstart for builtin plugins

Before You Open A PR

Make sure the following are true:

  • the code runs locally
  • tests pass
  • formatting hooks pass
  • docs and examples still match the current CLI behavior

Keeping changes small and focused makes review faster and reduces accidental regressions.

FAQ & Troubleshooting

uv sync --dev created .venv, but the shell is not activated

uv sync installs the environment, but it does not automatically source it.

Activate it manually:

source .venv/bin/activate

framex run starts, but my plugin is not loaded

Make sure you pass the correct load option.

Use --load-builtin-plugins for built-in plugins, or --load-plugins for your own plugin packages. Both options can be repeated.

framex run --load-builtin-plugins echo
framex run --load-plugins my_plugin --load-plugins another_plugin

pre-commit.ci reports files were modified by this hook

That means the hook reformatted files in CI.

Run the same formatter locally, commit the updated files, and push again:

pre-commit run --all-files

If the repo uses pre-commit.ci, the auto-fix only applies to writable pull request branches. It does not create a new pull request for you.

mypy complains about missing stubs

This usually means a third-party dependency does not ship type information.

There are two practical solutions.

Solution 1: Ignore That Dependency In Mypy

This is not the recommended option, but it is acceptable when no stub package exists.

Add a targeted ignore rule in mypy.ini or pyproject.toml.

For example, for mindforge:

[mypy-mindforge.*]
ignore_missing_imports = True

Avoid disabling missing-import checks globally. Keep the ignore rule scoped to the specific dependency.

Solution 2: Install The Stub Package

This is the recommended option when a matching type stub package exists.

For example:

uv add types-pytz --dev
uv add types-pyyaml --dev

Use this path for libraries such as:

  • pytz -> types-pytz
  • pyyaml -> types-pyyaml

If a stub package exists, prefer installing it over adding an ignore rule.