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.
@@ -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)