summonpot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
summonpot/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """summonpot — An API framework where every endpoint is an agent."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from summonpot.pot import Pot
6
+
7
+ try:
8
+ __version__ = version("summonpot")
9
+ except PackageNotFoundError:
10
+ __version__ = "0.0.0+unknown"
11
+
12
+ __all__ = ["Pot"]
summonpot/cli.py ADDED
@@ -0,0 +1,86 @@
1
+ """summonpot CLI — serve your Pot from the command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import importlib.util
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import typer
11
+
12
+ from summonpot.pot import Pot
13
+
14
+
15
+ def _version_callback(value: bool) -> None:
16
+ if value:
17
+ from importlib.metadata import version
18
+
19
+ typer.echo(f"summonpot {version('summonpot')}")
20
+ raise typer.Exit()
21
+
22
+
23
+ app = typer.Typer(
24
+ name="summonpot",
25
+ help="An AI-native API framework. Every endpoint is an agent that runs automatically.",
26
+ no_args_is_help=True,
27
+ )
28
+
29
+
30
+ @app.callback()
31
+ def main(
32
+ version: bool | None = typer.Option(
33
+ None,
34
+ "--version",
35
+ "-V",
36
+ callback=_version_callback,
37
+ is_eager=True,
38
+ help="Show version and exit.",
39
+ ),
40
+ ) -> None:
41
+ """An API framework where every endpoint is an agent."""
42
+
43
+
44
+ @app.command("serve")
45
+ def serve_command(
46
+ source: str = typer.Argument(
47
+ ...,
48
+ help="Path to a Python file containing a Pot instance named 'pot'.",
49
+ ),
50
+ host: str = typer.Option("0.0.0.0", "--host", help="Host to bind to."),
51
+ port: int = typer.Option(8000, "--port", "-p", help="Port to bind to."),
52
+ ) -> None:
53
+ """Serve a Pot file as an HTTP API."""
54
+ pot = _load_pot(source)
55
+ typer.echo(f"Summoning {pot.name} on http://{host}:{port}")
56
+ pot.serve(host=host, port=port)
57
+
58
+
59
+ def _load_pot(source: str) -> Pot:
60
+ """Load a Pot instance from a Python file."""
61
+ filepath = Path(source).resolve()
62
+ if not filepath.exists():
63
+ typer.echo(f"Error: file not found: {filepath}", err=True)
64
+ raise typer.Exit(1)
65
+
66
+ sys.path.insert(0, str(filepath.parent))
67
+ try:
68
+ spec = importlib.util.spec_from_file_location("_summonpot_user", filepath)
69
+ if spec is None or spec.loader is None:
70
+ typer.echo(f"Error: could not load module from {filepath}", err=True)
71
+ raise typer.Exit(1)
72
+ mod = importlib.util.module_from_spec(spec)
73
+ spec.loader.exec_module(mod)
74
+ except Exception as e:
75
+ typer.echo(f"Error loading {filepath}: {e}", err=True)
76
+ raise typer.Exit(1) from None
77
+
78
+ pot = getattr(mod, "pot", None)
79
+ if pot is None:
80
+ typer.echo(
81
+ f"Error: no 'pot' variable found in {filepath}. "
82
+ "Define a Pot instance named 'pot'.",
83
+ err=True,
84
+ )
85
+ raise typer.Exit(1)
86
+ return pot
summonpot/models.py ADDED
@@ -0,0 +1,89 @@
1
+ """Data models for summonpot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class ParamDef:
12
+ """A single parameter of an endpoint or tool."""
13
+
14
+ name: str
15
+ type_annotation: str = "str"
16
+ description: str = ""
17
+ required: bool = True
18
+ default: Any = None
19
+
20
+
21
+ @dataclass
22
+ class ToolDef:
23
+ """A tool registered with summonpot."""
24
+
25
+ name: str
26
+ description: str
27
+ parameters: list[ParamDef] = field(default_factory=list)
28
+ fn: Any = None # the callable
29
+
30
+ def to_openai_tool(self) -> dict:
31
+ """Serialize to an OpenAI-compatible tool definition."""
32
+ properties = {}
33
+ required = []
34
+ for p in self.parameters:
35
+ properties[p.name] = {
36
+ "type": _pytype_to_json(p.type_annotation),
37
+ "description": p.description,
38
+ }
39
+ if p.required:
40
+ required.append(p.name)
41
+ return {
42
+ "type": "function",
43
+ "function": {
44
+ "name": self.name,
45
+ "description": self.description,
46
+ "parameters": {
47
+ "type": "object",
48
+ "properties": properties,
49
+ "required": required,
50
+ },
51
+ },
52
+ }
53
+
54
+ async def call(self, **kwargs: Any) -> Any:
55
+ """Execute the tool with the given arguments."""
56
+ if inspect.iscoroutinefunction(self.fn):
57
+ return await self.fn(**kwargs)
58
+ return self.fn(**kwargs)
59
+
60
+
61
+ @dataclass
62
+ class EndpointDef:
63
+ """A registered endpoint summoned behind a route."""
64
+
65
+ path: str
66
+ name: str
67
+ description: str # docstring = system prompt
68
+ parameters: list[ParamDef] = field(default_factory=list)
69
+ return_type: str = "str"
70
+ tools: list[ToolDef] = field(default_factory=list)
71
+ stream: bool = False
72
+ model: str | None = None
73
+
74
+
75
+ def _pytype_to_json(type_str: str) -> str:
76
+ """Map a Python type name to a JSON schema type."""
77
+ mapping = {
78
+ "str": "string",
79
+ "int": "integer",
80
+ "float": "number",
81
+ "bool": "boolean",
82
+ "dict": "object",
83
+ "list": "array",
84
+ "Any": "string",
85
+ "None": "null",
86
+ }
87
+ # Handle Optional[str] etc. — just take the first non-None type
88
+ cleaned = type_str.replace("Optional[", "").replace("]", "")
89
+ return mapping.get(cleaned, "string")
summonpot/pot.py ADDED
@@ -0,0 +1,192 @@
1
+ """Pot — the summoning vessel. Register endpoints, summon agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from summonpot.models import EndpointDef, ParamDef
10
+ from summonpot.runtime import Runtime
11
+ from summonpot.tools import build_tool_from_func
12
+
13
+
14
+ class Pot:
15
+ """A summoning vessel for agentic endpoints.
16
+
17
+ Example::
18
+
19
+ from summonpot import Pot
20
+
21
+ pot = Pot(tools=[search_web])
22
+
23
+ @pot.summon("/research")
24
+ def research_topic(query: str) -> str:
25
+ \"\"\"Research this topic thoroughly.\"\"\"
26
+
27
+ pot.serve()
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ name: str | None = None,
33
+ tools: list | None = None,
34
+ ) -> None:
35
+ self.name = name or "summonpot"
36
+ # Convert any raw functions to ToolDef objects
37
+ self._pot_tools: list = []
38
+ if tools:
39
+ for t in tools:
40
+ if hasattr(t, "to_openai_tool"):
41
+ self._pot_tools.append(t)
42
+ else:
43
+ self._pot_tools.append(build_tool_from_func(t))
44
+ self._endpoints: list[EndpointDef] = []
45
+ self._runtime = Runtime()
46
+
47
+ def __repr__(self) -> str:
48
+ return f"Pot({self.name!r}, endpoints={len(self._endpoints)}, tools={len(self._pot_tools)})"
49
+
50
+ def summon(
51
+ self,
52
+ path: str,
53
+ *,
54
+ tools: list | None = None,
55
+ stream: bool = False,
56
+ model: str | None = None,
57
+ method: str = "POST",
58
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
59
+ """Decorator: summon an agent behind the given route.
60
+
61
+ Args:
62
+ path: URL path for the endpoint (e.g. ``/research``).
63
+ tools: Additional tools specific to this endpoint.
64
+ stream: Whether to stream the response.
65
+ model: LLM model override for this endpoint.
66
+ method: HTTP method (default POST).
67
+ """
68
+
69
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
70
+ endpoint_name = func.__name__
71
+ description = inspect.getdoc(func) or ""
72
+
73
+ # Merge pot-level tools with endpoint-specific tools
74
+ all_tools = list(self._pot_tools)
75
+ if tools:
76
+ # Convert raw functions to ToolDefs
77
+ for t in tools:
78
+ if not hasattr(t, "to_openai_tool"):
79
+ all_tools.append(build_tool_from_func(t))
80
+ else:
81
+ all_tools.append(t)
82
+
83
+ # Extract parameters from function signature
84
+ sig = inspect.signature(func)
85
+ hints = _safe_get_type_hints(func)
86
+ parameters: list[ParamDef] = []
87
+ for pname, param in sig.parameters.items():
88
+ if pname in ("self", "cls"):
89
+ continue
90
+ type_str = _get_type_str(pname, param, hints)
91
+ is_required = param.default is inspect.Parameter.empty
92
+ parameters.append(
93
+ ParamDef(
94
+ name=pname,
95
+ type_annotation=type_str,
96
+ description="",
97
+ required=is_required,
98
+ default=None if is_required else param.default,
99
+ )
100
+ )
101
+
102
+ # Return type
103
+ return_hint = hints.get("return", sig.return_annotation)
104
+ if return_hint is inspect.Parameter.empty or return_hint is None:
105
+ return_type = "str"
106
+ elif hasattr(return_hint, "__name__"):
107
+ return_type = return_hint.__name__
108
+ else:
109
+ return_type = str(return_hint)
110
+
111
+ endpoint = EndpointDef(
112
+ path=path,
113
+ name=endpoint_name,
114
+ description=description,
115
+ parameters=parameters,
116
+ return_type=return_type,
117
+ tools=all_tools,
118
+ stream=stream,
119
+ model=model,
120
+ )
121
+ self._endpoints.append(endpoint)
122
+ return func
123
+
124
+ return decorator
125
+
126
+ @property
127
+ def endpoints(self) -> list[EndpointDef]:
128
+ """Return all registered endpoints."""
129
+ return list(self._endpoints)
130
+
131
+ def serve(
132
+ self,
133
+ host: str = "0.0.0.0",
134
+ port: int = 8000,
135
+ ) -> None:
136
+ """Serve endpoints as an HTTP API.
137
+
138
+ Starts a FastAPI + uvicorn server.
139
+ Requires the ``serve`` extra: ``pip install summonpot[serve]``
140
+ """
141
+ self._serve_api(host, port)
142
+
143
+ def _serve_api(self, host: str, port: int) -> None:
144
+ try:
145
+ import uvicorn
146
+ except ImportError:
147
+ raise ModuleNotFoundError(
148
+ "uvicorn and fastapi are required for serving. "
149
+ "Install with: pip install summonpot[serve]"
150
+ ) from None
151
+
152
+ from summonpot.server import build_app
153
+
154
+ app = build_app(self)
155
+ uvicorn.run(app, host=host, port=port) # type: ignore[arg-type]
156
+
157
+
158
+ def _get_type_str(
159
+ pname: str,
160
+ param: inspect.Parameter,
161
+ hints: dict[str, Any],
162
+ ) -> str:
163
+ if pname in hints:
164
+ return _type_name(hints[pname])
165
+ if param.annotation is not inspect.Parameter.empty:
166
+ return _type_name(param.annotation)
167
+ return "str"
168
+
169
+
170
+ def _type_name(tp: Any) -> str:
171
+ if hasattr(tp, "__origin__"):
172
+ origin = tp.__origin__
173
+ args = tp.__args__
174
+ if origin is list and args:
175
+ return f"list[{_type_name(args[0])}]"
176
+ if origin is dict and len(args) >= 2:
177
+ return f"dict[{_type_name(args[0])}, {_type_name(args[1])}]"
178
+ if origin is tuple:
179
+ return f"tuple[{', '.join(_type_name(a) for a in args)}]"
180
+ return _type_name(origin)
181
+ if tp is type(None):
182
+ return "None"
183
+ if hasattr(tp, "__name__"):
184
+ return tp.__name__
185
+ return str(tp)
186
+
187
+
188
+ def _safe_get_type_hints(func: Callable[..., Any]) -> dict[str, Any]:
189
+ try:
190
+ return inspect.get_annotations(func, eval_str=True)
191
+ except Exception:
192
+ return inspect.get_annotations(func, eval_str=False)
summonpot/runtime.py ADDED
@@ -0,0 +1,150 @@
1
+ """Agent runtime — owns the LLM call loop for summonpot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from typing import Any
8
+
9
+ from summonpot.models import EndpointDef
10
+
11
+
12
+ class Runtime:
13
+ """Agent execution engine.
14
+
15
+ Calls an OpenAI-compatible API to fulfill an endpoint's intent.
16
+ In v0.1 this is a single LLM call with function calling (no multi-step loop).
17
+ The loop will deepen in v0.2+.
18
+ """
19
+
20
+ def __init__(self) -> None:
21
+ self.api_key = os.environ.get("SUMMONPOT_API_KEY") or os.environ.get(
22
+ "OPENAI_API_KEY", ""
23
+ )
24
+ self.base_url = os.environ.get(
25
+ "SUMMONPOT_BASE_URL",
26
+ os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"),
27
+ )
28
+ self.default_model = os.environ.get("SUMMONPOT_MODEL", "gpt-4o-mini")
29
+
30
+ async def call(
31
+ self,
32
+ endpoint: EndpointDef,
33
+ params: dict[str, Any],
34
+ ) -> Any:
35
+ """Execute an endpoint's agentic logic with the given parameters.
36
+
37
+ Builds the LLM request, calls the API, and returns the structured result.
38
+ """
39
+ if not self.api_key:
40
+ raise RuntimeError(
41
+ "No API key configured. Set SUMMONPOT_API_KEY or OPENAI_API_KEY."
42
+ )
43
+
44
+ model = endpoint.model or self.default_model
45
+ system_prompt = endpoint.description
46
+
47
+ # Build the user message — describe what the caller wants
48
+ user_message = self._build_user_message(endpoint, params)
49
+
50
+ # Build tool definitions for function calling
51
+ tools = [t.to_openai_tool() for t in endpoint.tools]
52
+
53
+ # Build the request body
54
+ body: dict[str, Any] = {
55
+ "model": model,
56
+ "messages": [
57
+ {"role": "system", "content": system_prompt},
58
+ {"role": "user", "content": user_message},
59
+ ],
60
+ }
61
+
62
+ if endpoint.return_type.lower() in ("str", "string"):
63
+ # Plain text response — no structured output enforcement
64
+ pass
65
+ elif endpoint.return_type in ("dict", "object", "Any"):
66
+ body["response_format"] = {"type": "json_object"}
67
+ else:
68
+ # For typed responses, try to use structured outputs
69
+ body["response_format"] = {"type": "json_object"}
70
+
71
+ if tools:
72
+ body["tools"] = tools
73
+ body["tool_choice"] = "auto"
74
+
75
+ # Make the API call
76
+ import httpx
77
+
78
+ async with httpx.AsyncClient(timeout=120.0) as client:
79
+ response = await client.post(
80
+ f"{self.base_url}/chat/completions",
81
+ headers={
82
+ "Authorization": f"Bearer {self.api_key}",
83
+ "Content-Type": "application/json",
84
+ },
85
+ json=body,
86
+ )
87
+ response.raise_for_status()
88
+ data = response.json()
89
+
90
+ message = data["choices"][0]["message"]
91
+
92
+ # Handle tool calls
93
+ if message.get("tool_calls"):
94
+ for tc in message["tool_calls"]:
95
+ tool_name = tc["function"]["name"]
96
+ tool_args = json.loads(tc["function"]["arguments"])
97
+ # Find the matching tool and execute it
98
+ for t in endpoint.tools:
99
+ if t.name == tool_name:
100
+ tool_result = await t.call(**tool_args)
101
+ # Append tool result to messages
102
+ body["messages"].append(message)
103
+ body["messages"].append(
104
+ {
105
+ "role": "tool",
106
+ "tool_call_id": tc["id"],
107
+ "content": json.dumps(tool_result, default=str),
108
+ }
109
+ )
110
+ break
111
+
112
+ # Get final response after tool calls
113
+ body.pop("tools", None)
114
+ body.pop("tool_choice", None)
115
+ async with httpx.AsyncClient(timeout=120.0) as client:
116
+ response = await client.post(
117
+ f"{self.base_url}/chat/completions",
118
+ headers={
119
+ "Authorization": f"Bearer {self.api_key}",
120
+ "Content-Type": "application/json",
121
+ },
122
+ json=body,
123
+ )
124
+ response.raise_for_status()
125
+ data = response.json()
126
+ message = data["choices"][0]["message"]
127
+
128
+ content = message.get("content", "")
129
+
130
+ # Parse structured output if needed
131
+ if endpoint.return_type.lower() not in ("str", "string", "any"):
132
+ try:
133
+ return json.loads(content)
134
+ except (json.JSONDecodeError, TypeError):
135
+ return content
136
+
137
+ return content
138
+
139
+ def _build_user_message(
140
+ self,
141
+ endpoint: EndpointDef,
142
+ params: dict[str, Any],
143
+ ) -> str:
144
+ """Build a user message from the endpoint's parameters."""
145
+ parts = [f"Endpoint: {endpoint.path}"]
146
+ if params:
147
+ parts.append("Parameters:")
148
+ for key, value in params.items():
149
+ parts.append(f" {key}: {json.dumps(value, default=str)}")
150
+ return "\n".join(parts)
summonpot/server.py ADDED
@@ -0,0 +1,106 @@
1
+ """HTTP server for summonpot — builds FastAPI routes from endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from summonpot import __version__
8
+
9
+ if TYPE_CHECKING:
10
+ from summonpot.pot import Pot
11
+
12
+
13
+ def build_app(pot: Pot) -> Any:
14
+ """Build a FastAPI application from a Pot instance."""
15
+ from fastapi import FastAPI
16
+
17
+ app = FastAPI(
18
+ title=pot.name,
19
+ description="An AI-native API framework. Every endpoint is an agent that runs automatically.",
20
+ version=__version__,
21
+ )
22
+
23
+ for endpoint in pot.endpoints:
24
+ route_path = endpoint.path
25
+ method = "POST"
26
+
27
+ if endpoint.parameters:
28
+ from pydantic import create_model
29
+
30
+ fields: dict[str, tuple[type, Any]] = {}
31
+ for p in endpoint.parameters:
32
+ field_type = _str_to_type(p.type_annotation)
33
+ if p.required:
34
+ fields[p.name] = (field_type, ...)
35
+ else:
36
+ fields[p.name] = (field_type, p.default)
37
+
38
+ RequestModel = create_model(
39
+ f"{endpoint.name}Request",
40
+ **fields, # pyright: ignore[reportArgumentType, reportCallIssue]
41
+ )
42
+
43
+ # Use a unique module-level attribute so FastAPI/Pydantic can resolve it
44
+ import sys as _sys
45
+
46
+ _attr = f"_RouteModel_{id(RequestModel)}"
47
+ setattr(_sys.modules[__name__], _attr, RequestModel)
48
+
49
+ # Resolve the model for the closure
50
+ resolved_model = RequestModel
51
+
52
+ async def _handle_with_body(
53
+ body: resolved_model, # type: ignore[valid-type]
54
+ _ep=endpoint,
55
+ _pt=pot,
56
+ ) -> Any:
57
+ params = body.model_dump() if hasattr(body, "model_dump") else body
58
+ return await _pt._runtime.call(_ep, params)
59
+
60
+ _handle_with_body.__annotations__["body"] = resolved_model
61
+
62
+ app.add_api_route(
63
+ route_path,
64
+ _handle_with_body,
65
+ methods=[method],
66
+ summary=(
67
+ endpoint.description.split("\n")[0]
68
+ if endpoint.description
69
+ else endpoint.name
70
+ ),
71
+ description=endpoint.description,
72
+ )
73
+ else:
74
+
75
+ async def _handle_without_body(ep=endpoint, pt=pot) -> Any:
76
+ return await pt._runtime.call(ep, {})
77
+
78
+ app.add_api_route(
79
+ route_path,
80
+ _handle_without_body,
81
+ methods=[method],
82
+ summary=(
83
+ endpoint.description.split("\n")[0]
84
+ if endpoint.description
85
+ else endpoint.name
86
+ ),
87
+ description=endpoint.description,
88
+ )
89
+
90
+ return app
91
+
92
+
93
+ def _str_to_type(type_str: str) -> type:
94
+ """Convert a type annotation string to a Python type."""
95
+ mapping: dict[str, type] = {
96
+ "str": str,
97
+ "int": int,
98
+ "float": float,
99
+ "bool": bool,
100
+ "list": list,
101
+ "dict": dict,
102
+ "Any": str,
103
+ "None": type(None),
104
+ }
105
+ base = type_str.split("[")[0].split("|")[0].strip()
106
+ return mapping.get(base, str)
summonpot/tools.py ADDED
@@ -0,0 +1,125 @@
1
+ """Tool registration and built-in tools for summonpot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from summonpot.models import ParamDef, ToolDef
10
+
11
+
12
+ def tool(
13
+ *,
14
+ name: str | None = None,
15
+ description: str | None = None,
16
+ ) -> Callable[[Callable[..., Any]], ToolDef]:
17
+ """Decorator to define a summonpot tool from a plain function.
18
+
19
+ Example::
20
+
21
+ from summonpot.tools import tool
22
+
23
+ @tool(name="search_web", description="Search the web for information")
24
+ def search_web(query: str) -> list[dict]:
25
+ \"\"\"Search the web.\"\"\"
26
+ return [{"title": "result"}]
27
+ """
28
+
29
+ def decorator(func: Callable[..., Any]) -> ToolDef:
30
+ tool_name = name or func.__name__
31
+ tool_desc = description or inspect.getdoc(func) or ""
32
+ sig = inspect.signature(func)
33
+ hints = _safe_get_type_hints(func)
34
+ params: list[ParamDef] = []
35
+ for pname, param in sig.parameters.items():
36
+ if pname in ("self", "cls"):
37
+ continue
38
+ type_str = _get_type_str(pname, param, hints)
39
+ is_required = param.default is inspect.Parameter.empty
40
+ description_text = ""
41
+ # Try to get param description from docstring
42
+ params.append(
43
+ ParamDef(
44
+ name=pname,
45
+ type_annotation=type_str,
46
+ description=description_text,
47
+ required=is_required,
48
+ default=None if is_required else param.default,
49
+ )
50
+ )
51
+ return ToolDef(
52
+ name=tool_name,
53
+ description=tool_desc,
54
+ parameters=params,
55
+ fn=func,
56
+ )
57
+
58
+ return decorator
59
+
60
+
61
+ def build_tool_from_func(func: Callable[..., Any]) -> ToolDef:
62
+ """Build a ToolDef from a raw function (no decorator)."""
63
+ tool_name = func.__name__
64
+ tool_desc = inspect.getdoc(func) or ""
65
+ sig = inspect.signature(func)
66
+ hints = _safe_get_type_hints(func)
67
+ params: list[ParamDef] = []
68
+ for pname, param in sig.parameters.items():
69
+ if pname in ("self", "cls"):
70
+ continue
71
+ type_str = _get_type_str(pname, param, hints)
72
+ is_required = param.default is inspect.Parameter.empty
73
+ params.append(
74
+ ParamDef(
75
+ name=pname,
76
+ type_annotation=type_str,
77
+ description="",
78
+ required=is_required,
79
+ default=None if is_required else param.default,
80
+ )
81
+ )
82
+ return ToolDef(
83
+ name=tool_name,
84
+ description=tool_desc,
85
+ parameters=params,
86
+ fn=func,
87
+ )
88
+
89
+
90
+ def _get_type_str(
91
+ pname: str,
92
+ param: inspect.Parameter,
93
+ hints: dict[str, Any],
94
+ ) -> str:
95
+ if pname in hints:
96
+ return _type_name(hints[pname])
97
+ if param.annotation is not inspect.Parameter.empty:
98
+ return _type_name(param.annotation)
99
+ return "str"
100
+
101
+
102
+ def _type_name(tp: Any) -> str:
103
+ """Convert a type annotation to a short string name."""
104
+ if hasattr(tp, "__origin__"):
105
+ origin = tp.__origin__
106
+ args = tp.__args__
107
+ if origin is list and args:
108
+ return f"list[{_type_name(args[0])}]"
109
+ if origin is dict and len(args) >= 2:
110
+ return f"dict[{_type_name(args[0])}, {_type_name(args[1])}]"
111
+ if origin is tuple:
112
+ return f"tuple[{', '.join(_type_name(a) for a in args)}]"
113
+ return _type_name(origin)
114
+ if tp is type(None):
115
+ return "None"
116
+ if hasattr(tp, "__name__"):
117
+ return tp.__name__
118
+ return str(tp)
119
+
120
+
121
+ def _safe_get_type_hints(func: Callable[..., Any]) -> dict[str, Any]:
122
+ try:
123
+ return inspect.get_annotations(func, eval_str=True)
124
+ except Exception:
125
+ return inspect.get_annotations(func, eval_str=False)
@@ -0,0 +1,222 @@
1
+ Metadata-Version: 2.4
2
+ Name: summonpot
3
+ Version: 0.1.0
4
+ Summary: An AI-native API framework. Every endpoint is an agent that runs automatically.
5
+ Project-URL: Homepage, https://github.com/tugrulguner/summonpot
6
+ Project-URL: Repository, https://github.com/tugrulguner/summonpot
7
+ Project-URL: Issues, https://github.com/tugrulguner/summonpot/issues
8
+ Project-URL: Changelog, https://github.com/tugrulguner/summonpot/blob/main/CHANGELOG.md
9
+ Author: Tugrul Guner
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agent,agentic,ai,api,django,fastapi,framework,llm
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: httpx>=0.27.0
24
+ Requires-Dist: pydantic>=2.0.0
25
+ Provides-Extra: all
26
+ Requires-Dist: fastapi>=0.100.0; extra == 'all'
27
+ Requires-Dist: typer>=0.9.0; extra == 'all'
28
+ Requires-Dist: uvicorn>=0.20.0; extra == 'all'
29
+ Provides-Extra: cli
30
+ Requires-Dist: typer>=0.9.0; extra == 'cli'
31
+ Provides-Extra: dev
32
+ Requires-Dist: fastapi>=0.100.0; extra == 'dev'
33
+ Requires-Dist: httpx>=0.27.0; extra == 'dev'
34
+ Requires-Dist: pre-commit>=4.0.0; extra == 'dev'
35
+ Requires-Dist: pyright>=1.1.390; extra == 'dev'
36
+ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
37
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
38
+ Requires-Dist: ruff>=0.8.0; extra == 'dev'
39
+ Requires-Dist: towncrier>=25.8.0; extra == 'dev'
40
+ Requires-Dist: typer>=0.9.0; extra == 'dev'
41
+ Requires-Dist: uvicorn>=0.20.0; extra == 'dev'
42
+ Provides-Extra: serve
43
+ Requires-Dist: fastapi>=0.100.0; extra == 'serve'
44
+ Requires-Dist: uvicorn>=0.20.0; extra == 'serve'
45
+ Description-Content-Type: text/markdown
46
+
47
+ # summonpot
48
+
49
+ <p align="center">
50
+ <img src="summonpot.png" alt="SummonPot" width="600">
51
+ </p>
52
+
53
+ [![CI](https://github.com/tugrulguner/summonpot/actions/workflows/ci.yml/badge.svg)](https://github.com/tugrulguner/summonpot/actions/workflows/ci.yml)
54
+ [![PyPI version](https://img.shields.io/pypi/v/summonpot)](https://pypi.org/project/summonpot/)
55
+ [![Python versions](https://img.shields.io/pypi/pyversions/summonpot)](https://pypi.org/project/summonpot/)
56
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
57
+
58
+ **An AI-native API framework. Every endpoint is an agent that runs automatically.**
59
+
60
+ summonpot is a **full API framework** — with routing, validation, middleware, and serving — but built for the era where APIs don't just respond, they reason. Define routes. The framework runs the agents. No agent configuration. No framework ontology. Just endpoints that think.
61
+
62
+ You define routes with a function signature, a docstring, and tools. The framework owns the agentic runtime — the LLM call loop, tool orchestration, structured output, and streaming. You don't configure an agent. You define an endpoint. The agent is summoned.
63
+
64
+ ```python
65
+ from summonpot import Pot
66
+
67
+ pot = Pot("my-service", tools=[search_web])
68
+
69
+ @pot.summon("/research")
70
+ def research_topic(query: str, depth: str = "standard") -> str:
71
+ """Research this topic thoroughly and return a comprehensive report."""
72
+
73
+ @pot.summon("/analyze")
74
+ def analyze_sentiment(text: str) -> dict:
75
+ """Analyze the text and return a JSON object with sentiment and topics."""
76
+
77
+ pot.serve()
78
+ ```
79
+
80
+ Call it like any API:
81
+
82
+ ```bash
83
+ curl -X POST http://localhost:8000/research \
84
+ -H "Content-Type: application/json" \
85
+ -d '{"query": "quantum computing", "depth": "deep"}'
86
+ ```
87
+
88
+ Behind the scenes, an agent runs — it thinks, uses tools, calls the LLM, enforces structured output, and returns the result. But you never wrote an agent. You wrote a route.
89
+
90
+ ## Why another framework?
91
+
92
+ Every existing approach to building agentic APIs has the same problem: you first learn an agent framework (LangChain, CrewAI, AutoGen), then bolt an HTTP server on top. The mental model is "configure an agent" — which is complex, brittle, and framework-y.
93
+
94
+ summonpot flips this: the **web framework IS the agent framework**. The routing is the agentic logic. The decorator is the incantation. The framework owns the smart parts.
95
+
96
+ | | Existing frameworks | summonpot |
97
+ |---|---|---|
98
+ | Mental model | "Configure an agent" | "Define an endpoint" |
99
+ | Surface area | Large (chains, agents, tools, memory, callbacks...) | Tiny (decorator + types + docstring) |
100
+ | API exposure | Bolt-on HTTP wrapper | Native (routing IS the agent) |
101
+ | Complexity | User manages the loop | Framework owns the loop, user provides intent |
102
+ | Testability | Heavy mocking required | Test like a regular HTTP endpoint |
103
+ | Onboarding | Learn the framework's ontology | If you know HTTP, you know this |
104
+
105
+ ## Installation
106
+
107
+ ```bash
108
+ pip install summonpot # core
109
+ pip install summonpot[serve] # + HTTP server (FastAPI/uvicorn)
110
+ pip install summonpot[cli] # + Typer CLI
111
+ pip install summonpot[all] # everything
112
+ ```
113
+
114
+ You also need an OpenAI-compatible API key:
115
+
116
+ ```bash
117
+ export SUMMONPOT_API_KEY=sk-... # or OPENAI_API_KEY
118
+ export SUMMONPOT_MODEL=gpt-4o-mini # optional, default gpt-4o-mini
119
+ export SUMMONPOT_BASE_URL=https://api.openai.com/v1 # optional
120
+ ```
121
+
122
+ ## Quick Start
123
+
124
+ Create a file `app.py`:
125
+
126
+ ```python
127
+ from summonpot import Pot
128
+
129
+ # A tool available to every endpoint
130
+ def search_web(query: str) -> list[dict]:
131
+ """Search the web for information."""
132
+ return [{"query": query, "result": "..."}]
133
+
134
+ pot = Pot("my-service", tools=[search_web])
135
+
136
+ @pot.summon("/research")
137
+ def research_topic(query: str, depth: str = "standard") -> str:
138
+ """Research this topic thoroughly and return a comprehensive report."""
139
+
140
+ @pot.summon("/summarize")
141
+ def summarize(text: str) -> str:
142
+ """Summarize the given text into key bullet points."""
143
+
144
+ @pot.summon("/analyze")
145
+ def analyze_sentiment(text: str) -> dict:
146
+ """Analyze the text and return a JSON object with sentiment and topics."""
147
+ ```
148
+
149
+ Serve it:
150
+
151
+ ```bash
152
+ summonpot serve app.py # serves on 0.0.0.0:8000
153
+ summonpot serve app.py --port 9000
154
+ ```
155
+
156
+ Or from Python:
157
+
158
+ ```python
159
+ pot.serve() # 0.0.0.0:8000
160
+ pot.serve(host="127.0.0.1", port=9000)
161
+ ```
162
+
163
+ ## The Summoning Model
164
+
165
+ | Concept | As summoning |
166
+ |---|---|
167
+ | Route definition | "At this path, I summon..." |
168
+ | Docstring | The incantation (system prompt) |
169
+ | Tools | Ingredients placed in the circle |
170
+ | Parameters | What the summoner brings |
171
+ | Return type | What appears |
172
+ | `stream=True` | You asked it to speak continuously |
173
+
174
+ ## How it works
175
+
176
+ summonpot inspects your endpoint function:
177
+
178
+ - **Docstring** → becomes the system prompt the agent follows
179
+ - **Parameters** → become the JSON request schema (validated by Pydantic)
180
+ - **Return type** → becomes the output contract (structured JSON for non-`str` types)
181
+ - **Tools** → exposed to the agent via function calling, so it can act, not just answer
182
+
183
+ The framework owns the LLM call loop, tool orchestration, and structured-output enforcement. You provide intent — the endpoint.
184
+
185
+ ## Configuration
186
+
187
+ | Variable | Default | Purpose |
188
+ |---|---|---|
189
+ | `SUMMONPOT_API_KEY` | `OPENAI_API_KEY` | API key for the LLM provider |
190
+ | `SUMMONPOT_BASE_URL` | `OPENAI_BASE_URL` or `https://api.openai.com/v1` | OpenAI-compatible endpoint |
191
+ | `SUMMONPOT_MODEL` | `gpt-4o-mini` | Default model for all endpoints |
192
+
193
+ Per-endpoint overrides:
194
+
195
+ ```python
196
+ @pot.summon("/research", model="gpt-4o", stream=True)
197
+ def research_topic(query: str) -> str:
198
+ """Research this topic."""
199
+ ```
200
+
201
+ ## Development
202
+
203
+ Requires [uv](https://docs.astral.sh/uv/).
204
+
205
+ ```bash
206
+ git clone https://github.com/tugrulguner/summonpot.git
207
+ cd summonpot
208
+ uv sync --all-extras
209
+ ```
210
+
211
+ ```bash
212
+ make check # lint + typecheck + test
213
+ make lint # ruff check + format check
214
+ make test # pytest
215
+ make format # auto-format
216
+ ```
217
+
218
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for pull-request and single-source release instructions.
219
+
220
+ ## License
221
+
222
+ MIT
@@ -0,0 +1,12 @@
1
+ summonpot/__init__.py,sha256=Pr4MRN_1Dw3xNieTL2xx_Q08cYyBXdAMi3tO87z8CeQ,291
2
+ summonpot/cli.py,sha256=Wvev2Ik7SYND9dV50z5Bsmf_vZ-fR-n5uykrGexayr8,2408
3
+ summonpot/models.py,sha256=K2JrPGB_hvNmUyomxM5PTj4HvaHCYNbptaotmzH7w8A,2429
4
+ summonpot/pot.py,sha256=HgNU6GbwuK7foMR0DXUh4NTBsJ-uNBtVeyou5NLtrJ8,6153
5
+ summonpot/runtime.py,sha256=tIVzw374mA_jXFVVgPFKFDQL6z5beP35FTV-kGWJ-Qs,5270
6
+ summonpot/server.py,sha256=beL8W1PwZYBdLF3hb5KNk8OP58RC6PjHeBf67NjG6-E,3279
7
+ summonpot/tools.py,sha256=PiKaafCVSnAJ2asOcFy9DbLgYN3YkGcdDYk2WSUyCr8,3880
8
+ summonpot-0.1.0.dist-info/METADATA,sha256=eE3RZETfNK6DVEbJez_Mo_w55Fg_zEkmtvAlt_d2JZ0,8168
9
+ summonpot-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
10
+ summonpot-0.1.0.dist-info/entry_points.txt,sha256=leA2JLXJckzzm73eCDCmEAjcgQN2OLpffpdL8M4HthY,48
11
+ summonpot-0.1.0.dist-info/licenses/LICENSE,sha256=RMhMT4PHyQK019lq5As5W0qclDwaFF59uSkoui8hyVg,1069
12
+ summonpot-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ summonpot = summonpot.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tugrul Guner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.