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 +12 -0
- summonpot/cli.py +86 -0
- summonpot/models.py +89 -0
- summonpot/pot.py +192 -0
- summonpot/runtime.py +150 -0
- summonpot/server.py +106 -0
- summonpot/tools.py +125 -0
- summonpot-0.1.0.dist-info/METADATA +222 -0
- summonpot-0.1.0.dist-info/RECORD +12 -0
- summonpot-0.1.0.dist-info/WHEEL +4 -0
- summonpot-0.1.0.dist-info/entry_points.txt +2 -0
- summonpot-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
[](https://github.com/tugrulguner/summonpot/actions/workflows/ci.yml)
|
|
54
|
+
[](https://pypi.org/project/summonpot/)
|
|
55
|
+
[](https://pypi.org/project/summonpot/)
|
|
56
|
+
[](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,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.
|