api-operator 0.9.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.
- api_operator/__init__.py +8 -0
- api_operator/adapters/__init__.py +1 -0
- api_operator/adapters/base.py +25 -0
- api_operator/adapters/mock.py +145 -0
- api_operator/adapters/openapi_generator.py +238 -0
- api_operator/adapters/registry.py +71 -0
- api_operator/adapters/yaml_adapter.py +175 -0
- api_operator/adapters/yaml_spec.py +154 -0
- api_operator/core/agent.py +160 -0
- api_operator/core/config.py +18 -0
- api_operator/core/executor.py +38 -0
- api_operator/core/guardrails.py +53 -0
- api_operator/core/memory.py +46 -0
- api_operator/core/planner.py +189 -0
- api_operator/core/planner_openai.py +71 -0
- api_operator/factory.py +26 -0
- api_operator/rag/indexer.py +37 -0
- api_operator/server/app.py +114 -0
- api_operator/server/cli.py +201 -0
- api_operator/tools/base.py +69 -0
- api_operator/tools/schema.py +34 -0
- api_operator-0.9.0.dist-info/METADATA +206 -0
- api_operator-0.9.0.dist-info/RECORD +26 -0
- api_operator-0.9.0.dist-info/WHEEL +4 -0
- api_operator-0.9.0.dist-info/entry_points.txt +2 -0
- api_operator-0.9.0.dist-info/licenses/LICENSE +21 -0
api_operator/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""API Operator — standalone AI operator with pluggable adapters."""
|
|
2
|
+
|
|
3
|
+
from api_operator.core.agent import Agent, AgentResponse
|
|
4
|
+
from api_operator.core.config import Settings
|
|
5
|
+
from api_operator.tools.base import Tool, ToolRegistry
|
|
6
|
+
|
|
7
|
+
__all__ = ["Agent", "AgentResponse", "Settings", "Tool", "ToolRegistry"]
|
|
8
|
+
__version__ = "0.9.0"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from api_operator.tools.base import ToolRegistry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Adapter(ABC):
|
|
10
|
+
name: str
|
|
11
|
+
description: str
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def build_registry(self) -> ToolRegistry:
|
|
15
|
+
raise NotImplementedError
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def system_prompt(self) -> str:
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
|
|
21
|
+
def auth_context(self) -> dict[str, Any]:
|
|
22
|
+
return {}
|
|
23
|
+
|
|
24
|
+
def docs_paths(self) -> list[str]:
|
|
25
|
+
return []
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from api_operator.adapters.base import Adapter
|
|
6
|
+
from api_operator.tools.base import Tool, ToolRegistry, ToolResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _MockStore:
|
|
10
|
+
workspaces: dict[str, dict[str, Any]] = {}
|
|
11
|
+
invitations: list[dict[str, Any]] = []
|
|
12
|
+
connections: dict[str, dict[str, Any]] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def reset_mock_store() -> None:
|
|
16
|
+
_MockStore.workspaces.clear()
|
|
17
|
+
_MockStore.invitations.clear()
|
|
18
|
+
_MockStore.connections.clear()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MockAdapter(Adapter):
|
|
22
|
+
name = "mock"
|
|
23
|
+
description = "In-memory demo adapter for SaaS + connectivity scenarios"
|
|
24
|
+
|
|
25
|
+
def build_registry(self) -> ToolRegistry:
|
|
26
|
+
registry = ToolRegistry()
|
|
27
|
+
registry.register(
|
|
28
|
+
Tool(
|
|
29
|
+
name="list_workspaces",
|
|
30
|
+
description="List all mock workspaces",
|
|
31
|
+
handler=self._list_workspaces,
|
|
32
|
+
parameters={},
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
registry.register(
|
|
36
|
+
Tool(
|
|
37
|
+
name="create_workspace",
|
|
38
|
+
description="Create a mock workspace with name and subdomain",
|
|
39
|
+
handler=self._create_workspace,
|
|
40
|
+
parameters={"name": str, "subdomain": str},
|
|
41
|
+
required=["name", "subdomain"],
|
|
42
|
+
dangerous=True,
|
|
43
|
+
requires_ability="workspaces:write",
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
registry.register(
|
|
47
|
+
Tool(
|
|
48
|
+
name="invite_member",
|
|
49
|
+
description="Invite a team member to a workspace by subdomain",
|
|
50
|
+
handler=self._invite_member,
|
|
51
|
+
parameters={"subdomain": str, "email": str, "role": str},
|
|
52
|
+
required=["subdomain", "email"],
|
|
53
|
+
dangerous=True,
|
|
54
|
+
requires_ability="team:invite",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
registry.register(
|
|
58
|
+
Tool(
|
|
59
|
+
name="list_connections",
|
|
60
|
+
description="List network connections (connectivity demo)",
|
|
61
|
+
handler=self._list_connections,
|
|
62
|
+
parameters={},
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
registry.register(
|
|
66
|
+
Tool(
|
|
67
|
+
name="provision_link",
|
|
68
|
+
description="Provision a network link between two sites",
|
|
69
|
+
handler=self._provision_link,
|
|
70
|
+
parameters={"site_a": str, "site_b": str, "bandwidth_mbps": int},
|
|
71
|
+
required=["site_a", "site_b"],
|
|
72
|
+
dangerous=True,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
return registry
|
|
76
|
+
|
|
77
|
+
def system_prompt(self) -> str:
|
|
78
|
+
return (
|
|
79
|
+
"You are API Operator (mock mode). You help operators manage "
|
|
80
|
+
"workspaces and network links using registered tools only. "
|
|
81
|
+
"Reply in the user's language (Arabic or English). "
|
|
82
|
+
"Never claim an action succeeded unless a tool returned ok=true."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async def _list_workspaces(self) -> ToolResult:
|
|
86
|
+
items = list(_MockStore.workspaces.values())
|
|
87
|
+
return ToolResult(ok=True, data={"workspaces": items, "count": len(items)})
|
|
88
|
+
|
|
89
|
+
async def _create_workspace(self, name: str, subdomain: str) -> ToolResult:
|
|
90
|
+
key = subdomain.strip().lower()
|
|
91
|
+
if key in _MockStore.workspaces:
|
|
92
|
+
return ToolResult(ok=False, error=f"Workspace '{key}' already exists.")
|
|
93
|
+
workspace = {
|
|
94
|
+
"id": key,
|
|
95
|
+
"name": name.strip(),
|
|
96
|
+
"subdomain": key,
|
|
97
|
+
"url": f"https://{key}.example.test",
|
|
98
|
+
}
|
|
99
|
+
_MockStore.workspaces[key] = workspace
|
|
100
|
+
return ToolResult(ok=True, data=workspace)
|
|
101
|
+
|
|
102
|
+
async def _invite_member(
|
|
103
|
+
self,
|
|
104
|
+
subdomain: str,
|
|
105
|
+
email: str,
|
|
106
|
+
role: str = "member",
|
|
107
|
+
) -> ToolResult:
|
|
108
|
+
key = subdomain.strip().lower()
|
|
109
|
+
if key not in _MockStore.workspaces:
|
|
110
|
+
return ToolResult(ok=False, error=f"Workspace '{key}' not found.")
|
|
111
|
+
invitation = {
|
|
112
|
+
"workspace": key,
|
|
113
|
+
"email": email.strip().lower(),
|
|
114
|
+
"role": role.strip().lower() or "member",
|
|
115
|
+
"status": "sent",
|
|
116
|
+
}
|
|
117
|
+
_MockStore.invitations.append(invitation)
|
|
118
|
+
return ToolResult(ok=True, data=invitation)
|
|
119
|
+
|
|
120
|
+
async def _list_connections(self) -> ToolResult:
|
|
121
|
+
items = list(_MockStore.connections.values())
|
|
122
|
+
return ToolResult(ok=True, data={"connections": items, "count": len(items)})
|
|
123
|
+
|
|
124
|
+
async def _provision_link(
|
|
125
|
+
self,
|
|
126
|
+
site_a: str,
|
|
127
|
+
site_b: str,
|
|
128
|
+
bandwidth_mbps: int = 100,
|
|
129
|
+
) -> ToolResult:
|
|
130
|
+
link_id = f"{site_a.strip().lower()}-{site_b.strip().lower()}"
|
|
131
|
+
if link_id in _MockStore.connections:
|
|
132
|
+
return ToolResult(ok=False, error=f"Link '{link_id}' already exists.")
|
|
133
|
+
link = {
|
|
134
|
+
"id": link_id,
|
|
135
|
+
"site_a": site_a.strip(),
|
|
136
|
+
"site_b": site_b.strip(),
|
|
137
|
+
"bandwidth_mbps": bandwidth_mbps,
|
|
138
|
+
"status": "active",
|
|
139
|
+
}
|
|
140
|
+
_MockStore.connections[link_id] = link
|
|
141
|
+
return ToolResult(ok=True, data=link)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_mock_store() -> _MockStore:
|
|
145
|
+
return _MockStore
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from api_operator.adapters.yaml_spec import AdapterSpec, ParameterSpec, ToolSpec, save_adapter_spec
|
|
11
|
+
|
|
12
|
+
_METHODS = {"get", "post", "put", "patch", "delete"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def generate_adapter_from_openapi(
|
|
16
|
+
spec_path: str | Path,
|
|
17
|
+
*,
|
|
18
|
+
base_url: str,
|
|
19
|
+
name: str | None = None,
|
|
20
|
+
description: str | None = None,
|
|
21
|
+
path_prefix: str | None = None,
|
|
22
|
+
include_methods: set[str] | None = None,
|
|
23
|
+
tag_filter: set[str] | None = None,
|
|
24
|
+
dangerous_paths: set[str] | None = None,
|
|
25
|
+
) -> AdapterSpec:
|
|
26
|
+
path = Path(spec_path)
|
|
27
|
+
text = path.read_text(encoding="utf-8")
|
|
28
|
+
if path.suffix.lower() == ".json":
|
|
29
|
+
raw = json.loads(text)
|
|
30
|
+
else:
|
|
31
|
+
raw = yaml.safe_load(text)
|
|
32
|
+
if not isinstance(raw, dict):
|
|
33
|
+
raise ValueError("OpenAPI spec must be a YAML/JSON object")
|
|
34
|
+
|
|
35
|
+
info = raw.get("info") or {}
|
|
36
|
+
adapter_name = name or str(info.get("title", path.stem)).lower().replace(" ", "_")
|
|
37
|
+
adapter_description = description or str(info.get("description", f"{adapter_name} API adapter"))
|
|
38
|
+
methods = {m.upper() for m in (include_methods or {"GET", "POST"})}
|
|
39
|
+
dangerous_paths = dangerous_paths or set()
|
|
40
|
+
|
|
41
|
+
tools: list[ToolSpec] = []
|
|
42
|
+
paths = raw.get("paths") or {}
|
|
43
|
+
for route, path_item in paths.items():
|
|
44
|
+
if not isinstance(path_item, dict):
|
|
45
|
+
continue
|
|
46
|
+
if path_prefix and not route.startswith(path_prefix):
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
for method, operation in path_item.items():
|
|
50
|
+
if method.lower() not in _METHODS or not isinstance(operation, dict):
|
|
51
|
+
continue
|
|
52
|
+
if method.upper() not in methods:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
tags = operation.get("tags") or []
|
|
56
|
+
if tag_filter and not (set(tags) & tag_filter):
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
tool = _operation_to_tool(route, method.upper(), operation, dangerous_paths)
|
|
60
|
+
if tool:
|
|
61
|
+
tools.append(tool)
|
|
62
|
+
|
|
63
|
+
if not tools:
|
|
64
|
+
raise ValueError("No tools generated — check path_prefix, methods, or tag_filter.")
|
|
65
|
+
|
|
66
|
+
return AdapterSpec(
|
|
67
|
+
name=adapter_name,
|
|
68
|
+
description=adapter_description,
|
|
69
|
+
base_url=base_url.rstrip("/"),
|
|
70
|
+
system_prompt=(
|
|
71
|
+
f"You are API Operator for {adapter_name}. "
|
|
72
|
+
"Use tools to call the project API. Confirm dangerous actions. "
|
|
73
|
+
"Reply in the user's language."
|
|
74
|
+
),
|
|
75
|
+
tools=tools,
|
|
76
|
+
token_env=f"{adapter_name.upper()}_API_TOKEN",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _operation_to_tool(
|
|
81
|
+
route: str,
|
|
82
|
+
method: str,
|
|
83
|
+
operation: dict[str, Any],
|
|
84
|
+
dangerous_paths: set[str],
|
|
85
|
+
) -> ToolSpec | None:
|
|
86
|
+
operation_id = operation.get("operationId")
|
|
87
|
+
summary = operation.get("summary") or operation.get("description") or f"{method} {route}"
|
|
88
|
+
name = _tool_name(operation_id, method, route)
|
|
89
|
+
parameters: dict[str, ParameterSpec] = {}
|
|
90
|
+
path = route
|
|
91
|
+
|
|
92
|
+
for param in operation.get("parameters") or []:
|
|
93
|
+
if not isinstance(param, dict):
|
|
94
|
+
continue
|
|
95
|
+
pname = param.get("name")
|
|
96
|
+
if not pname:
|
|
97
|
+
continue
|
|
98
|
+
location = param.get("in")
|
|
99
|
+
required = bool(param.get("required", False))
|
|
100
|
+
schema = param.get("schema") or {}
|
|
101
|
+
ptype = str(schema.get("type", "string"))
|
|
102
|
+
parameters[str(pname)] = ParameterSpec(type=ptype, required=required)
|
|
103
|
+
if location == "path":
|
|
104
|
+
path = path.replace(f"{{{pname}}}", f"{{{pname}}}")
|
|
105
|
+
|
|
106
|
+
body_template: dict[str, Any] | None = None
|
|
107
|
+
request_body = operation.get("requestBody") or {}
|
|
108
|
+
content = request_body.get("content") or {}
|
|
109
|
+
json_content = content.get("application/json") or {}
|
|
110
|
+
schema = json_content.get("schema") or {}
|
|
111
|
+
props = schema.get("properties") or {}
|
|
112
|
+
required_fields = set(schema.get("required") or [])
|
|
113
|
+
if props:
|
|
114
|
+
body_template = {}
|
|
115
|
+
for pname, pschema in props.items():
|
|
116
|
+
if not isinstance(pschema, dict):
|
|
117
|
+
continue
|
|
118
|
+
parameters.setdefault(
|
|
119
|
+
str(pname),
|
|
120
|
+
ParameterSpec(
|
|
121
|
+
type=str(pschema.get("type", "string")),
|
|
122
|
+
required=str(pname) in required_fields,
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
body_template[str(pname)] = f"{{{pname}}}"
|
|
126
|
+
|
|
127
|
+
dangerous = method in {"POST", "PUT", "PATCH", "DELETE"} or route in dangerous_paths
|
|
128
|
+
|
|
129
|
+
return ToolSpec(
|
|
130
|
+
name=name,
|
|
131
|
+
description=str(summary).strip(),
|
|
132
|
+
method=method,
|
|
133
|
+
path=path,
|
|
134
|
+
parameters=parameters,
|
|
135
|
+
body=body_template,
|
|
136
|
+
dangerous=dangerous,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _tool_name(operation_id: str | None, method: str, route: str) -> str:
|
|
141
|
+
if operation_id:
|
|
142
|
+
return _slugify(operation_id)
|
|
143
|
+
slug = _slugify(route.replace("{", "").replace("}", "").replace("/", "_"))
|
|
144
|
+
return _slugify(f"{method.lower()}_{slug}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _slugify(value: str) -> str:
|
|
148
|
+
value = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value)
|
|
149
|
+
value = value.strip().lower()
|
|
150
|
+
value = re.sub(r"[^a-z0-9_]+", "_", value)
|
|
151
|
+
value = re.sub(r"_+", "_", value).strip("_")
|
|
152
|
+
return value[:64] or "tool"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
SCAFFOLD_TEMPLATE = """name: {name}
|
|
156
|
+
description: {description}
|
|
157
|
+
base_url: {base_url}
|
|
158
|
+
|
|
159
|
+
auth:
|
|
160
|
+
type: bearer
|
|
161
|
+
header: Authorization
|
|
162
|
+
env_token: {token_env}
|
|
163
|
+
|
|
164
|
+
system_prompt: |
|
|
165
|
+
You are API Operator for {name}.
|
|
166
|
+
Use registered tools only. Confirm dangerous actions.
|
|
167
|
+
Reply in the user's language (Arabic or English).
|
|
168
|
+
|
|
169
|
+
tools:
|
|
170
|
+
- name: list_items
|
|
171
|
+
description: List items from the project API
|
|
172
|
+
method: GET
|
|
173
|
+
path: /api/items
|
|
174
|
+
requires_ability: items:read
|
|
175
|
+
|
|
176
|
+
- name: create_item
|
|
177
|
+
description: Create a new item
|
|
178
|
+
method: POST
|
|
179
|
+
path: /api/items
|
|
180
|
+
dangerous: true
|
|
181
|
+
requires_ability: items:write
|
|
182
|
+
parameters:
|
|
183
|
+
title:
|
|
184
|
+
type: string
|
|
185
|
+
required: true
|
|
186
|
+
body:
|
|
187
|
+
title: "{{title}}"
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def scaffold_adapter(
|
|
192
|
+
name: str,
|
|
193
|
+
output_dir: str | Path,
|
|
194
|
+
*,
|
|
195
|
+
base_url: str = "http://localhost:8000",
|
|
196
|
+
description: str | None = None,
|
|
197
|
+
) -> Path:
|
|
198
|
+
directory = Path(output_dir)
|
|
199
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
200
|
+
config_path = directory / "adapter.yaml"
|
|
201
|
+
token_env = f"{name.upper()}_API_TOKEN"
|
|
202
|
+
config_path.write_text(
|
|
203
|
+
SCAFFOLD_TEMPLATE.format(
|
|
204
|
+
name=name,
|
|
205
|
+
description=description or f"{name} API adapter",
|
|
206
|
+
base_url=base_url.rstrip("/"),
|
|
207
|
+
token_env=token_env,
|
|
208
|
+
),
|
|
209
|
+
encoding="utf-8",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
readme = directory / "README.md"
|
|
213
|
+
readme.write_text(
|
|
214
|
+
f"""# {name} adapter (YAML)
|
|
215
|
+
|
|
216
|
+
No Python required — edit `adapter.yaml` and run:
|
|
217
|
+
|
|
218
|
+
```powershell
|
|
219
|
+
python -m api_operator.server.cli chat --adapter yaml --config adapter.yaml
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Set token:
|
|
223
|
+
|
|
224
|
+
```powershell
|
|
225
|
+
$env:{token_env} = "your-api-token"
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Or pass `--token` on the CLI.
|
|
229
|
+
|
|
230
|
+
Generate from OpenAPI:
|
|
231
|
+
|
|
232
|
+
```powershell
|
|
233
|
+
python -m api_operator.server.cli generate-from-openapi openapi.yaml --output adapter.yaml --base-url {base_url}
|
|
234
|
+
```
|
|
235
|
+
""",
|
|
236
|
+
encoding="utf-8",
|
|
237
|
+
)
|
|
238
|
+
return config_path
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from api_operator.adapters.base import Adapter
|
|
7
|
+
from api_operator.adapters.mock import MockAdapter
|
|
8
|
+
from api_operator.adapters.yaml_adapter import YamlAdapter
|
|
9
|
+
|
|
10
|
+
_BUILTIN: dict[str, type[Adapter]] = {
|
|
11
|
+
"mock": MockAdapter,
|
|
12
|
+
"yaml": YamlAdapter,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
_REGISTERED: dict[str, type[Adapter]] = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register_adapter(name: str, adapter_cls: type[Adapter]) -> None:
|
|
19
|
+
"""Register a custom adapter at runtime (e.g. from your project bootstrap)."""
|
|
20
|
+
_REGISTERED[name] = adapter_cls
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def import_adapter_class(reference: str) -> type[Adapter]:
|
|
24
|
+
"""
|
|
25
|
+
Import adapter class from 'module.path:ClassName'.
|
|
26
|
+
Used for adapters that live outside the core package.
|
|
27
|
+
"""
|
|
28
|
+
if ":" not in reference:
|
|
29
|
+
raise ValueError("adapter_class must be 'module.path:ClassName'")
|
|
30
|
+
module_path, class_name = reference.split(":", 1)
|
|
31
|
+
module = importlib.import_module(module_path)
|
|
32
|
+
cls = getattr(module, class_name)
|
|
33
|
+
if not isinstance(cls, type) or not issubclass(cls, Adapter):
|
|
34
|
+
raise TypeError(f"{reference} is not an Adapter subclass")
|
|
35
|
+
return cls
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def available_adapters() -> list[str]:
|
|
39
|
+
return sorted({*_BUILTIN.keys(), *_REGISTERED.keys()})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_adapter(
|
|
43
|
+
name: str | None = None,
|
|
44
|
+
*,
|
|
45
|
+
adapter_class: str | None = None,
|
|
46
|
+
config_path: str | None = None,
|
|
47
|
+
**kwargs: Any,
|
|
48
|
+
) -> Adapter:
|
|
49
|
+
if adapter_class:
|
|
50
|
+
cls = import_adapter_class(adapter_class)
|
|
51
|
+
return cls(**kwargs)
|
|
52
|
+
|
|
53
|
+
if name is None:
|
|
54
|
+
name = "mock"
|
|
55
|
+
|
|
56
|
+
if name == "mock":
|
|
57
|
+
return MockAdapter()
|
|
58
|
+
|
|
59
|
+
if name == "yaml":
|
|
60
|
+
if not config_path:
|
|
61
|
+
raise ValueError("yaml adapter requires config_path (adapter.yaml).")
|
|
62
|
+
return YamlAdapter(config_path=config_path, **kwargs)
|
|
63
|
+
|
|
64
|
+
cls = _REGISTERED.get(name) or _BUILTIN.get(name)
|
|
65
|
+
if cls is None:
|
|
66
|
+
available = ", ".join(available_adapters())
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"Unknown adapter '{name}'. Built-in: {available}. "
|
|
69
|
+
"Use --adapter yaml --config adapter.yaml or adapter_class='module:Class'."
|
|
70
|
+
)
|
|
71
|
+
return cls(**kwargs) # type: ignore[call-arg]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from api_operator.adapters.base import Adapter
|
|
10
|
+
from api_operator.adapters.yaml_spec import AdapterSpec, ToolSpec, load_adapter_spec
|
|
11
|
+
from api_operator.tools.base import Tool, ToolRegistry, ToolResult
|
|
12
|
+
|
|
13
|
+
_PLACEHOLDER = re.compile(r"\{(\w+)\}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class YamlAdapter(Adapter):
|
|
17
|
+
"""
|
|
18
|
+
HTTP adapter driven by a YAML config file — no Python required per project.
|
|
19
|
+
|
|
20
|
+
See examples/tenant-kit-adapter/adapter.yaml and `scaffold-adapter` CLI.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name = "yaml"
|
|
24
|
+
description = "YAML-configured HTTP adapter"
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
config_path: str,
|
|
29
|
+
token: str | None = None,
|
|
30
|
+
base_url: str | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.spec = load_adapter_spec(config_path)
|
|
33
|
+
self.name = self.spec.name
|
|
34
|
+
self.description = self.spec.description
|
|
35
|
+
self.token = token or _token_from_env(self.spec.token_env)
|
|
36
|
+
if base_url:
|
|
37
|
+
self.spec.base_url = base_url.rstrip("/")
|
|
38
|
+
|
|
39
|
+
def auth_context(self) -> dict[str, Any]:
|
|
40
|
+
return {"token": "***" if self.token else None, "base_url": self.spec.base_url}
|
|
41
|
+
|
|
42
|
+
def build_registry(self) -> ToolRegistry:
|
|
43
|
+
registry = ToolRegistry()
|
|
44
|
+
for tool_spec in self.spec.tools:
|
|
45
|
+
registry.register(self._build_tool(tool_spec))
|
|
46
|
+
return registry
|
|
47
|
+
|
|
48
|
+
def system_prompt(self) -> str:
|
|
49
|
+
return self.spec.system_prompt
|
|
50
|
+
|
|
51
|
+
def _build_tool(self, spec: ToolSpec) -> Tool:
|
|
52
|
+
param_types: dict[str, type | str] = {}
|
|
53
|
+
required: list[str] = []
|
|
54
|
+
for name, param in spec.parameters.items():
|
|
55
|
+
param_types[name] = param.type
|
|
56
|
+
if param.required:
|
|
57
|
+
required.append(name)
|
|
58
|
+
|
|
59
|
+
async def handler(**kwargs: Any) -> ToolResult:
|
|
60
|
+
return await self._execute(spec, kwargs)
|
|
61
|
+
|
|
62
|
+
return Tool(
|
|
63
|
+
name=spec.name,
|
|
64
|
+
description=spec.description,
|
|
65
|
+
handler=handler,
|
|
66
|
+
parameters=param_types,
|
|
67
|
+
required=required,
|
|
68
|
+
dangerous=spec.dangerous,
|
|
69
|
+
requires_ability=spec.requires_ability,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
async def _execute(self, spec: ToolSpec, args: dict[str, Any]) -> ToolResult:
|
|
73
|
+
if self.spec.auth_type == "bearer" and not self.token:
|
|
74
|
+
return ToolResult(ok=False, error="Missing API token for YAML adapter.")
|
|
75
|
+
|
|
76
|
+
for name, param in spec.parameters.items():
|
|
77
|
+
if args.get(name) is None and param.default is not None:
|
|
78
|
+
args[name] = param.default
|
|
79
|
+
|
|
80
|
+
url = self._build_url(spec, args)
|
|
81
|
+
json_body = self._build_body(spec.body, args) if spec.body else None
|
|
82
|
+
params = self._build_query(spec.query, args) if spec.query else None
|
|
83
|
+
|
|
84
|
+
headers = {"Accept": "application/json"}
|
|
85
|
+
if json_body is not None:
|
|
86
|
+
headers["Content-Type"] = "application/json"
|
|
87
|
+
if self.token and self.spec.auth_type == "bearer":
|
|
88
|
+
headers[self.spec.auth_header] = f"Bearer {self.token}"
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
92
|
+
response = await client.request(
|
|
93
|
+
spec.method,
|
|
94
|
+
url,
|
|
95
|
+
headers=headers,
|
|
96
|
+
json=json_body,
|
|
97
|
+
params=params,
|
|
98
|
+
)
|
|
99
|
+
except httpx.HTTPError as exc:
|
|
100
|
+
return ToolResult(ok=False, error=f"HTTP error: {exc}")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
body = response.json()
|
|
104
|
+
except ValueError:
|
|
105
|
+
body = {"raw": response.text}
|
|
106
|
+
|
|
107
|
+
if response.is_success:
|
|
108
|
+
return ToolResult(ok=True, data=body if isinstance(body, dict) else {"data": body})
|
|
109
|
+
|
|
110
|
+
error = body.get("message") if isinstance(body, dict) else response.text
|
|
111
|
+
return ToolResult(
|
|
112
|
+
ok=False,
|
|
113
|
+
error=f"API {response.status_code}: {error}",
|
|
114
|
+
data=body if isinstance(body, dict) else {},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _build_url(self, spec: ToolSpec, args: dict[str, Any]) -> str:
|
|
118
|
+
path = spec.path
|
|
119
|
+
for match in _PLACEHOLDER.finditer(path):
|
|
120
|
+
key = match.group(1)
|
|
121
|
+
if key not in args:
|
|
122
|
+
raise ValueError(f"Missing path parameter: {key}")
|
|
123
|
+
path = path.replace(f"{{{key}}}", str(args[key]))
|
|
124
|
+
|
|
125
|
+
base = self._resolve_base(spec, args)
|
|
126
|
+
return f"{base}{path}"
|
|
127
|
+
|
|
128
|
+
def _resolve_base(self, spec: ToolSpec, args: dict[str, Any]) -> str:
|
|
129
|
+
if spec.host != "tenant":
|
|
130
|
+
return self.spec.base_url
|
|
131
|
+
|
|
132
|
+
subdomain_key = spec.tenant_param
|
|
133
|
+
subdomain = args.get(subdomain_key)
|
|
134
|
+
if not subdomain:
|
|
135
|
+
raise ValueError(f"Missing tenant parameter: {subdomain_key}")
|
|
136
|
+
|
|
137
|
+
return _tenant_base_url(self.spec.base_url, str(subdomain))
|
|
138
|
+
|
|
139
|
+
def _build_body(self, template: dict[str, Any], args: dict[str, Any]) -> dict[str, Any]:
|
|
140
|
+
return _render_mapping(template, args)
|
|
141
|
+
|
|
142
|
+
def _build_query(self, template: dict[str, str], args: dict[str, Any]) -> dict[str, str]:
|
|
143
|
+
return {key: str(_render_value(value, args)) for key, value in template.items()}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _tenant_base_url(base_url: str, subdomain: str) -> str:
|
|
147
|
+
if "://" not in base_url:
|
|
148
|
+
raise ValueError("base_url must include scheme")
|
|
149
|
+
scheme, rest = base_url.split("://", 1)
|
|
150
|
+
host = rest.split("/", 1)[0]
|
|
151
|
+
return f"{scheme}://{subdomain}.{host}"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _render_mapping(template: dict[str, Any], args: dict[str, Any]) -> dict[str, Any]:
|
|
155
|
+
return {key: _render_value(value, args) for key, value in template.items()}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _render_value(value: Any, args: dict[str, Any]) -> Any:
|
|
159
|
+
if isinstance(value, str):
|
|
160
|
+
rendered = value
|
|
161
|
+
for match in _PLACEHOLDER.finditer(value):
|
|
162
|
+
key = match.group(1)
|
|
163
|
+
if key not in args:
|
|
164
|
+
raise ValueError(f"Missing body parameter: {key}")
|
|
165
|
+
rendered = rendered.replace(f"{{{key}}}", str(args[key]))
|
|
166
|
+
return rendered
|
|
167
|
+
if isinstance(value, dict):
|
|
168
|
+
return _render_mapping(value, args)
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _token_from_env(env_name: str | None) -> str | None:
|
|
173
|
+
if not env_name:
|
|
174
|
+
return None
|
|
175
|
+
return os.environ.get(env_name)
|