api-operator 0.9.0__tar.gz → 0.10.0__tar.gz
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-0.9.0 → api_operator-0.10.0}/CHANGELOG.md +15 -0
- api_operator-0.10.0/Dockerfile +12 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/PKG-INFO +1 -1
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/adapters/mock.py +36 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/adapters/yaml_adapter.py +37 -1
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/adapters/yaml_spec.py +3 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/core/agent.py +2 -1
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/core/config.py +1 -0
- api_operator-0.10.0/api_operator/core/formatters.py +169 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/core/planner.py +65 -6
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/server/app.py +12 -1
- {api_operator-0.9.0 → api_operator-0.10.0}/examples/tenant-kit-adapter/adapter.yaml +10 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/pyproject.toml +1 -1
- {api_operator-0.9.0 → api_operator-0.10.0}/tests/test_api.py +1 -1
- {api_operator-0.9.0 → api_operator-0.10.0}/tests/test_cli.py +1 -1
- {api_operator-0.9.0 → api_operator-0.10.0}/tests/test_core.py +24 -0
- api_operator-0.10.0/tests/test_formatters.py +36 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/tests/test_mock_adapter.py +1 -1
- {api_operator-0.9.0 → api_operator-0.10.0}/tests/test_scenarios_e2e.py +2 -1
- {api_operator-0.9.0 → api_operator-0.10.0}/tests/test_yaml_adapter.py +36 -1
- {api_operator-0.9.0 → api_operator-0.10.0}/.env.example +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/.github/workflows/tests.yml +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/.gitignore +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/LICENSE +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/README.md +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/__init__.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/adapters/__init__.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/adapters/base.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/adapters/openapi_generator.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/adapters/registry.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/core/executor.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/core/guardrails.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/core/memory.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/core/planner_openai.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/factory.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/rag/indexer.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/server/cli.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/tools/base.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/tools/schema.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/examples/tenant-kit-adapter/README.md +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/scripts/integration_tenant_kit.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/tests/__init__.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/tests/test_agent.py +0 -0
- {api_operator-0.9.0 → api_operator-0.10.0}/tests/test_integration_tenant_kit.py +0 -0
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.10.0] - 2026-06-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- YAML adapter `connect_host` — route HTTP to an internal host (e.g. Docker `nginx`) while preserving logical `Host` headers for multi-tenant subdomains
|
|
10
|
+
- Optional CORS for `api-operator serve` via `API_OPERATOR_CORS_ORIGINS` (comma-separated)
|
|
11
|
+
- `Dockerfile` for containerized deployments
|
|
12
|
+
- `formatters.py` — human-readable tool success messages (no raw JSON in chat replies)
|
|
13
|
+
- Mock adapter tools: `get_usage`, `get_subscription`
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Mock planner — friendlier help text and NL patterns for tenant-kit flows
|
|
18
|
+
- FastAPI app version metadata updated to 0.10.0
|
|
19
|
+
|
|
5
20
|
## [0.9.0] - 2026-06-14
|
|
6
21
|
|
|
7
22
|
Renamed to **`api-operator`** (GitHub + PyPI). PyPI blocks names starting with `operator` (stdlib conflict).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
FROM python:3.12-slim
|
|
2
|
+
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
|
|
5
|
+
COPY pyproject.toml README.md LICENSE ./
|
|
6
|
+
COPY api_operator ./api_operator
|
|
7
|
+
|
|
8
|
+
RUN pip install --no-cache-dir .
|
|
9
|
+
|
|
10
|
+
EXPOSE 8100
|
|
11
|
+
|
|
12
|
+
CMD ["api-operator", "serve", "--host", "0.0.0.0", "--port", "8100", "--adapter", "yaml", "--planner", "mock"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: api-operator
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Standalone AI API Operator with pluggable adapters for multi-project APIs
|
|
5
5
|
Project-URL: Homepage, https://github.com/mohammedelkarsh/api-operator
|
|
6
6
|
Project-URL: Repository, https://github.com/mohammedelkarsh/api-operator
|
|
@@ -62,6 +62,24 @@ class MockAdapter(Adapter):
|
|
|
62
62
|
parameters={},
|
|
63
63
|
)
|
|
64
64
|
)
|
|
65
|
+
registry.register(
|
|
66
|
+
Tool(
|
|
67
|
+
name="get_usage",
|
|
68
|
+
description="Get usage metrics for a workspace",
|
|
69
|
+
handler=self._get_usage,
|
|
70
|
+
parameters={"workspace_id": str},
|
|
71
|
+
required=["workspace_id"],
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
registry.register(
|
|
75
|
+
Tool(
|
|
76
|
+
name="get_subscription",
|
|
77
|
+
description="Get subscription details for a workspace",
|
|
78
|
+
handler=self._get_subscription,
|
|
79
|
+
parameters={"workspace_id": str},
|
|
80
|
+
required=["workspace_id"],
|
|
81
|
+
)
|
|
82
|
+
)
|
|
65
83
|
registry.register(
|
|
66
84
|
Tool(
|
|
67
85
|
name="provision_link",
|
|
@@ -121,6 +139,24 @@ class MockAdapter(Adapter):
|
|
|
121
139
|
items = list(_MockStore.connections.values())
|
|
122
140
|
return ToolResult(ok=True, data={"connections": items, "count": len(items)})
|
|
123
141
|
|
|
142
|
+
async def _get_usage(self, workspace_id: str) -> ToolResult:
|
|
143
|
+
key = workspace_id.strip().lower()
|
|
144
|
+
if key not in _MockStore.workspaces and key != "demo":
|
|
145
|
+
return ToolResult(ok=False, error=f"Workspace '{key}' not found.")
|
|
146
|
+
return ToolResult(
|
|
147
|
+
ok=True,
|
|
148
|
+
data={"workspace_id": key, "api_calls": 42, "storage_mb": 128},
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def _get_subscription(self, workspace_id: str) -> ToolResult:
|
|
152
|
+
key = workspace_id.strip().lower()
|
|
153
|
+
if key not in _MockStore.workspaces and key != "demo":
|
|
154
|
+
return ToolResult(ok=False, error=f"Workspace '{key}' not found.")
|
|
155
|
+
return ToolResult(
|
|
156
|
+
ok=True,
|
|
157
|
+
data={"workspace_id": key, "plan": "pro", "status": "active"},
|
|
158
|
+
)
|
|
159
|
+
|
|
124
160
|
async def _provision_link(
|
|
125
161
|
self,
|
|
126
162
|
site_a: str,
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
5
|
from typing import Any
|
|
6
|
+
from urllib.parse import urlparse, urlunparse
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
8
9
|
|
|
@@ -28,6 +29,7 @@ class YamlAdapter(Adapter):
|
|
|
28
29
|
config_path: str,
|
|
29
30
|
token: str | None = None,
|
|
30
31
|
base_url: str | None = None,
|
|
32
|
+
connect_host: str | None = None,
|
|
31
33
|
) -> None:
|
|
32
34
|
self.spec = load_adapter_spec(config_path)
|
|
33
35
|
self.name = self.spec.name
|
|
@@ -35,6 +37,7 @@ class YamlAdapter(Adapter):
|
|
|
35
37
|
self.token = token or _token_from_env(self.spec.token_env)
|
|
36
38
|
if base_url:
|
|
37
39
|
self.spec.base_url = base_url.rstrip("/")
|
|
40
|
+
self.connect_host = connect_host or self.spec.connect_host
|
|
38
41
|
|
|
39
42
|
def auth_context(self) -> dict[str, Any]:
|
|
40
43
|
return {"token": "***" if self.token else None, "base_url": self.spec.base_url}
|
|
@@ -87,11 +90,13 @@ class YamlAdapter(Adapter):
|
|
|
87
90
|
if self.token and self.spec.auth_type == "bearer":
|
|
88
91
|
headers[self.spec.auth_header] = f"Bearer {self.token}"
|
|
89
92
|
|
|
93
|
+
request_url, headers = _apply_connect_host(url, self.connect_host, headers)
|
|
94
|
+
|
|
90
95
|
try:
|
|
91
96
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
92
97
|
response = await client.request(
|
|
93
98
|
spec.method,
|
|
94
|
-
|
|
99
|
+
request_url,
|
|
95
100
|
headers=headers,
|
|
96
101
|
json=json_body,
|
|
97
102
|
params=params,
|
|
@@ -173,3 +178,34 @@ def _token_from_env(env_name: str | None) -> str | None:
|
|
|
173
178
|
if not env_name:
|
|
174
179
|
return None
|
|
175
180
|
return os.environ.get(env_name)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _apply_connect_host(
|
|
184
|
+
url: str,
|
|
185
|
+
connect_host: str | None,
|
|
186
|
+
headers: dict[str, str],
|
|
187
|
+
) -> tuple[str, dict[str, str]]:
|
|
188
|
+
if not connect_host:
|
|
189
|
+
return url, headers
|
|
190
|
+
|
|
191
|
+
parsed = urlparse(url)
|
|
192
|
+
if not parsed.hostname:
|
|
193
|
+
return url, headers
|
|
194
|
+
|
|
195
|
+
host_header = parsed.hostname
|
|
196
|
+
if parsed.port:
|
|
197
|
+
host_header = f"{host_header}:{parsed.port}"
|
|
198
|
+
|
|
199
|
+
headers = dict(headers)
|
|
200
|
+
headers["Host"] = host_header
|
|
201
|
+
request_url = urlunparse(
|
|
202
|
+
(
|
|
203
|
+
parsed.scheme,
|
|
204
|
+
connect_host,
|
|
205
|
+
parsed.path,
|
|
206
|
+
parsed.params,
|
|
207
|
+
parsed.query,
|
|
208
|
+
parsed.fragment,
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
return request_url, headers
|
|
@@ -39,6 +39,7 @@ class AdapterSpec:
|
|
|
39
39
|
auth_type: str = "bearer"
|
|
40
40
|
auth_header: str = "Authorization"
|
|
41
41
|
token_env: str | None = None
|
|
42
|
+
connect_host: str | None = None
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
def load_adapter_spec(path: str | Path) -> AdapterSpec:
|
|
@@ -84,6 +85,7 @@ def load_adapter_spec(path: str | Path) -> AdapterSpec:
|
|
|
84
85
|
)
|
|
85
86
|
)
|
|
86
87
|
|
|
88
|
+
connect_host = raw.get("connect_host")
|
|
87
89
|
return AdapterSpec(
|
|
88
90
|
name=str(raw.get("name", file_path.stem)),
|
|
89
91
|
description=str(raw.get("description", "")),
|
|
@@ -93,6 +95,7 @@ def load_adapter_spec(path: str | Path) -> AdapterSpec:
|
|
|
93
95
|
auth_type=str(auth.get("type", "bearer")),
|
|
94
96
|
auth_header=str(auth.get("header", "Authorization")),
|
|
95
97
|
token_env=auth.get("env_token"),
|
|
98
|
+
connect_host=str(connect_host).strip() if connect_host else None,
|
|
96
99
|
)
|
|
97
100
|
|
|
98
101
|
|
|
@@ -6,6 +6,7 @@ from typing import Any
|
|
|
6
6
|
from api_operator.adapters.base import Adapter
|
|
7
7
|
from api_operator.core.config import Settings
|
|
8
8
|
from api_operator.core.executor import ToolExecutor
|
|
9
|
+
from api_operator.core.formatters import format_tool_success
|
|
9
10
|
from api_operator.core.guardrails import Guardrails
|
|
10
11
|
from api_operator.core.memory import Session, SessionStore
|
|
11
12
|
from api_operator.core.planner import Planner
|
|
@@ -136,7 +137,7 @@ class Agent:
|
|
|
136
137
|
|
|
137
138
|
payload = result.to_dict() if result else {"ok": False}
|
|
138
139
|
if payload.get("ok"):
|
|
139
|
-
reply =
|
|
140
|
+
reply = format_tool_success(tool_name, payload.get("data") or {})
|
|
140
141
|
session.add("assistant", reply, status="ok", tool=tool_name)
|
|
141
142
|
return AgentResponse(
|
|
142
143
|
session_id=session.id,
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def format_tool_success(tool_name: str, data: Any) -> str:
|
|
7
|
+
if not isinstance(data, dict):
|
|
8
|
+
return f"{tool_name.replace('_', ' ')} completed successfully."
|
|
9
|
+
|
|
10
|
+
formatters = {
|
|
11
|
+
"list_workspaces": _format_list_workspaces,
|
|
12
|
+
"create_workspace": _format_create_workspace,
|
|
13
|
+
"get_usage": _format_get_usage,
|
|
14
|
+
"get_subscription": _format_get_subscription,
|
|
15
|
+
"invite_member": _format_invite,
|
|
16
|
+
"invite_team_member": _format_invite,
|
|
17
|
+
"list_connections": _format_list_connections,
|
|
18
|
+
"provision_link": _format_provision_link,
|
|
19
|
+
}
|
|
20
|
+
formatter = formatters.get(tool_name)
|
|
21
|
+
if formatter is not None:
|
|
22
|
+
return formatter(data)
|
|
23
|
+
|
|
24
|
+
return _format_generic(tool_name, data)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _workspace_items(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
28
|
+
payload = data.get("data", data)
|
|
29
|
+
if isinstance(payload, list):
|
|
30
|
+
return [item for item in payload if isinstance(item, dict)]
|
|
31
|
+
if isinstance(payload, dict):
|
|
32
|
+
nested = payload.get("workspaces")
|
|
33
|
+
if isinstance(nested, list):
|
|
34
|
+
return [item for item in nested if isinstance(item, dict)]
|
|
35
|
+
if any(key in payload for key in ("id", "name", "subdomain")):
|
|
36
|
+
return [payload]
|
|
37
|
+
nested = data.get("workspaces")
|
|
38
|
+
if isinstance(nested, list):
|
|
39
|
+
return [item for item in nested if isinstance(item, dict)]
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _format_list_workspaces(data: dict[str, Any]) -> str:
|
|
44
|
+
items = _workspace_items(data)
|
|
45
|
+
if not items:
|
|
46
|
+
return "No workspaces found."
|
|
47
|
+
|
|
48
|
+
lines = [f"Found {len(items)} workspace(s):", ""]
|
|
49
|
+
for workspace in items:
|
|
50
|
+
name = workspace.get("name") or workspace.get("id") or "Unknown"
|
|
51
|
+
identifier = workspace.get("id") or workspace.get("subdomain") or "?"
|
|
52
|
+
line = f"• {name} ({identifier})"
|
|
53
|
+
url = workspace.get("url")
|
|
54
|
+
if url:
|
|
55
|
+
line += f"\n {url}"
|
|
56
|
+
status_bits: list[str] = []
|
|
57
|
+
if workspace.get("suspended"):
|
|
58
|
+
status_bits.append("suspended")
|
|
59
|
+
if workspace.get("subscribed"):
|
|
60
|
+
status_bits.append("subscribed")
|
|
61
|
+
if status_bits:
|
|
62
|
+
line += f"\n Status: {', '.join(status_bits)}"
|
|
63
|
+
lines.append(line)
|
|
64
|
+
|
|
65
|
+
return "\n".join(lines)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _format_create_workspace(data: dict[str, Any]) -> str:
|
|
69
|
+
workspace = data.get("data", data)
|
|
70
|
+
if not isinstance(workspace, dict):
|
|
71
|
+
return "Workspace created successfully."
|
|
72
|
+
|
|
73
|
+
name = workspace.get("name") or workspace.get("id") or "Workspace"
|
|
74
|
+
identifier = workspace.get("id") or workspace.get("subdomain") or "?"
|
|
75
|
+
lines = [f"Workspace created: {name} ({identifier})"]
|
|
76
|
+
url = workspace.get("url")
|
|
77
|
+
if url:
|
|
78
|
+
lines.append(url)
|
|
79
|
+
return "\n".join(lines)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _format_get_usage(data: dict[str, Any]) -> str:
|
|
83
|
+
payload = data.get("data", data)
|
|
84
|
+
if not isinstance(payload, dict):
|
|
85
|
+
return "Usage retrieved."
|
|
86
|
+
|
|
87
|
+
workspace_id = payload.get("workspace_id", "workspace")
|
|
88
|
+
lines = [f"Usage for {workspace_id}:"]
|
|
89
|
+
for key, value in payload.items():
|
|
90
|
+
if key == "workspace_id":
|
|
91
|
+
continue
|
|
92
|
+
label = key.replace("_", " ").strip()
|
|
93
|
+
lines.append(f"• {label}: {value}")
|
|
94
|
+
return "\n".join(lines)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _format_get_subscription(data: dict[str, Any]) -> str:
|
|
98
|
+
payload = data.get("data", data)
|
|
99
|
+
if not isinstance(payload, dict):
|
|
100
|
+
return "Subscription retrieved."
|
|
101
|
+
|
|
102
|
+
workspace_id = payload.get("workspace_id", "workspace")
|
|
103
|
+
plan = payload.get("plan", "unknown")
|
|
104
|
+
status = payload.get("status", "unknown")
|
|
105
|
+
return f"Subscription for {workspace_id}: {plan} ({status})"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _format_invite(data: dict[str, Any]) -> str:
|
|
109
|
+
payload = data.get("data", data)
|
|
110
|
+
if not isinstance(payload, dict):
|
|
111
|
+
return "Invitation sent."
|
|
112
|
+
|
|
113
|
+
email = payload.get("email", "member")
|
|
114
|
+
workspace = payload.get("workspace") or payload.get("subdomain") or "workspace"
|
|
115
|
+
role = payload.get("role")
|
|
116
|
+
line = f"Invitation sent to {email} for {workspace}"
|
|
117
|
+
if role:
|
|
118
|
+
line += f" as {role}"
|
|
119
|
+
return f"{line}."
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _format_list_connections(data: dict[str, Any]) -> str:
|
|
123
|
+
payload = data.get("data", data)
|
|
124
|
+
items: list[Any]
|
|
125
|
+
if isinstance(payload, dict):
|
|
126
|
+
nested = payload.get("connections")
|
|
127
|
+
items = nested if isinstance(nested, list) else []
|
|
128
|
+
elif isinstance(payload, list):
|
|
129
|
+
items = payload
|
|
130
|
+
else:
|
|
131
|
+
items = []
|
|
132
|
+
|
|
133
|
+
if not items:
|
|
134
|
+
return "No connections found."
|
|
135
|
+
|
|
136
|
+
lines = [f"Found {len(items)} connection(s):", ""]
|
|
137
|
+
for item in items:
|
|
138
|
+
if not isinstance(item, dict):
|
|
139
|
+
continue
|
|
140
|
+
site_a = item.get("site_a", "?")
|
|
141
|
+
site_b = item.get("site_b", "?")
|
|
142
|
+
bandwidth = item.get("bandwidth_mbps")
|
|
143
|
+
line = f"• {site_a} ↔ {site_b}"
|
|
144
|
+
if bandwidth is not None:
|
|
145
|
+
line += f" ({bandwidth} Mbps)"
|
|
146
|
+
lines.append(line)
|
|
147
|
+
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _format_provision_link(data: dict[str, Any]) -> str:
|
|
152
|
+
payload = data.get("data", data)
|
|
153
|
+
if not isinstance(payload, dict):
|
|
154
|
+
return "Link provisioned successfully."
|
|
155
|
+
|
|
156
|
+
site_a = payload.get("site_a", "?")
|
|
157
|
+
site_b = payload.get("site_b", "?")
|
|
158
|
+
bandwidth = payload.get("bandwidth_mbps")
|
|
159
|
+
line = f"Link provisioned: {site_a} ↔ {site_b}"
|
|
160
|
+
if bandwidth is not None:
|
|
161
|
+
line += f" at {bandwidth} Mbps"
|
|
162
|
+
return line
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _format_generic(tool_name: str, data: dict[str, Any]) -> str:
|
|
166
|
+
label = tool_name.replace("_", " ")
|
|
167
|
+
if data.get("message") and len(data) == 1:
|
|
168
|
+
return str(data["message"])
|
|
169
|
+
return f"{label} completed successfully."
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import re
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
from typing import Any, Protocol
|
|
5
6
|
|
|
@@ -38,13 +39,12 @@ class MockPlanner:
|
|
|
38
39
|
lower = text.lower()
|
|
39
40
|
|
|
40
41
|
if "help" in lower or "مساعدة" in text or lower.strip() == "tools":
|
|
41
|
-
names = ", ".join(registry.names())
|
|
42
42
|
return PlanStep(
|
|
43
43
|
type="respond",
|
|
44
44
|
content=(
|
|
45
|
-
"I can
|
|
46
|
-
|
|
47
|
-
"
|
|
45
|
+
"I'm your Tenant Kit assistant. I can help with workspaces "
|
|
46
|
+
"(list, create), billing (usage, subscription), and team invites. "
|
|
47
|
+
"Use the chat menu buttons for a guided step-by-step flow."
|
|
48
48
|
),
|
|
49
49
|
)
|
|
50
50
|
|
|
@@ -83,6 +83,22 @@ class MockPlanner:
|
|
|
83
83
|
if args.get("site_a") and args.get("site_b") and registry.get("provision_link"):
|
|
84
84
|
return PlanStep(type="tool", tool_name="provision_link", tool_args=args)
|
|
85
85
|
|
|
86
|
+
if _wants_get_subscription(lower, text) and registry.get("get_subscription"):
|
|
87
|
+
workspace_id = _extract_workspace_id(text) or "demo"
|
|
88
|
+
return PlanStep(
|
|
89
|
+
type="tool",
|
|
90
|
+
tool_name="get_subscription",
|
|
91
|
+
tool_args={"workspace_id": workspace_id},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if _wants_get_usage(lower, text) and registry.get("get_usage"):
|
|
95
|
+
workspace_id = _extract_workspace_id(text) or "demo"
|
|
96
|
+
return PlanStep(
|
|
97
|
+
type="tool",
|
|
98
|
+
tool_name="get_usage",
|
|
99
|
+
tool_args={"workspace_id": workspace_id},
|
|
100
|
+
)
|
|
101
|
+
|
|
86
102
|
if _wants_list_connections(lower, text) and registry.get("list_connections"):
|
|
87
103
|
return PlanStep(type="tool", tool_name="list_connections", tool_args={})
|
|
88
104
|
|
|
@@ -102,8 +118,10 @@ def _wants_create_workspace(lower: str) -> bool:
|
|
|
102
118
|
return (
|
|
103
119
|
"create workspace" in lower
|
|
104
120
|
or "new workspace" in lower
|
|
121
|
+
or "add workspace" in lower
|
|
105
122
|
or "أنشئ workspace" in lower
|
|
106
123
|
or "انشئ workspace" in lower
|
|
124
|
+
or bool(re.search(r"\b(create|add|new)\b.*\bworkspace", lower))
|
|
107
125
|
)
|
|
108
126
|
|
|
109
127
|
|
|
@@ -122,21 +140,62 @@ def _wants_list_connections(lower: str, text: str) -> bool:
|
|
|
122
140
|
|
|
123
141
|
|
|
124
142
|
def _wants_list_workspaces(lower: str, text: str) -> bool:
|
|
125
|
-
if "workspace" not in lower and "workspace" not in text:
|
|
126
|
-
return False
|
|
127
143
|
if _wants_create_workspace(lower):
|
|
128
144
|
return False
|
|
145
|
+
if "workspace" not in lower and "workspace" not in text and "مساح" not in text:
|
|
146
|
+
return False
|
|
147
|
+
if re.search(r"\b(usage|subscription|invite)\b", lower):
|
|
148
|
+
return False
|
|
129
149
|
return (
|
|
130
150
|
"list workspace" in lower
|
|
131
151
|
or "list workspaces" in lower
|
|
152
|
+
or "list of workspace" in lower
|
|
132
153
|
or "show workspace" in lower
|
|
133
154
|
or "show workspaces" in lower
|
|
155
|
+
or "show me workspace" in lower
|
|
156
|
+
or "get workspace" in lower
|
|
157
|
+
or "get workspaces" in lower
|
|
158
|
+
or "what workspace" in lower
|
|
159
|
+
or "which workspace" in lower
|
|
134
160
|
or lower.strip() == "workspaces"
|
|
135
161
|
or "ورّيني" in text
|
|
136
162
|
or "اعرض" in text
|
|
163
|
+
or bool(re.search(r"\b(list|show|get|display|what|which|all)\b.*\bworkspaces?\b", lower))
|
|
164
|
+
or bool(re.search(r"\bworkspaces?\b.*\b(list|show|all)\b", lower))
|
|
165
|
+
or bool(re.search(r"\bi want\b.*\bworkspaces?\b", lower))
|
|
137
166
|
)
|
|
138
167
|
|
|
139
168
|
|
|
169
|
+
def _wants_get_usage(lower: str, text: str) -> bool:
|
|
170
|
+
return "usage" in lower or "استخدام" in text or "الاستخدام" in text
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _wants_get_subscription(lower: str, text: str) -> bool:
|
|
174
|
+
return (
|
|
175
|
+
"subscription" in lower
|
|
176
|
+
or "subscribe" in lower
|
|
177
|
+
or "اشتراك" in text
|
|
178
|
+
or "الاشتراك" in text
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _extract_workspace_id(text: str) -> str | None:
|
|
183
|
+
lower = text.lower()
|
|
184
|
+
for token in ("for ", "workspace "):
|
|
185
|
+
if token in lower:
|
|
186
|
+
segment = lower.split(token, 1)[1].strip().split()
|
|
187
|
+
if segment:
|
|
188
|
+
candidate = segment[0].strip(".,?!")
|
|
189
|
+
if candidate and candidate not in {"a", "the", "my"}:
|
|
190
|
+
return candidate
|
|
191
|
+
match = re.search(r"\b([a-z0-9][a-z0-9-]{1,62})\b", lower)
|
|
192
|
+
if match and match.group(1) not in {"demo", "usage", "subscription", "for", "get"}:
|
|
193
|
+
return match.group(1)
|
|
194
|
+
if "demo" in lower:
|
|
195
|
+
return "demo"
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
140
199
|
def _extract_workspace_args(text: str) -> dict[str, Any]:
|
|
141
200
|
args: dict[str, Any] = {}
|
|
142
201
|
lower = text.lower()
|
|
@@ -32,15 +32,26 @@ class ChatResponse(BaseModel):
|
|
|
32
32
|
|
|
33
33
|
def create_app(settings: Settings | None = None):
|
|
34
34
|
from fastapi import FastAPI, HTTPException
|
|
35
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
35
36
|
|
|
36
37
|
settings = settings or Settings()
|
|
37
38
|
session_store = SessionStore()
|
|
38
39
|
app = FastAPI(
|
|
39
40
|
title="API Operator",
|
|
40
|
-
version="0.
|
|
41
|
+
version="0.10.0",
|
|
41
42
|
description="Standalone AI operator with pluggable adapters",
|
|
42
43
|
)
|
|
43
44
|
|
|
45
|
+
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
|
46
|
+
if origins:
|
|
47
|
+
app.add_middleware(
|
|
48
|
+
CORSMiddleware,
|
|
49
|
+
allow_origins=origins,
|
|
50
|
+
allow_credentials=True,
|
|
51
|
+
allow_methods=["*"],
|
|
52
|
+
allow_headers=["*"],
|
|
53
|
+
)
|
|
54
|
+
|
|
44
55
|
@app.get("/health")
|
|
45
56
|
async def health() -> dict[str, str]:
|
|
46
57
|
return {"status": "ok", "adapter_default": settings.default_adapter}
|
|
@@ -57,6 +57,16 @@ tools:
|
|
|
57
57
|
type: string
|
|
58
58
|
required: true
|
|
59
59
|
|
|
60
|
+
- name: get_usage
|
|
61
|
+
description: Get current billing period usage meters for a workspace
|
|
62
|
+
method: GET
|
|
63
|
+
path: /api/workspaces/{workspace_id}/usage
|
|
64
|
+
requires_ability: workspaces:read
|
|
65
|
+
parameters:
|
|
66
|
+
workspace_id:
|
|
67
|
+
type: string
|
|
68
|
+
required: true
|
|
69
|
+
|
|
60
70
|
- name: invite_team_member
|
|
61
71
|
description: Invite a member on the tenant subdomain API
|
|
62
72
|
method: POST
|
|
@@ -50,7 +50,7 @@ async def test_tools_endpoint_mock(app):
|
|
|
50
50
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
51
51
|
response = await client.get("/v1/tools?adapter=mock")
|
|
52
52
|
assert response.status_code == 200
|
|
53
|
-
assert len(response.json()["tools"]) ==
|
|
53
|
+
assert len(response.json()["tools"]) == 7
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
@pytest.mark.asyncio
|
|
@@ -13,7 +13,7 @@ def test_cli_demo():
|
|
|
13
13
|
reset_mock_store()
|
|
14
14
|
result = runner.invoke(app, ["demo"])
|
|
15
15
|
assert result.exit_code == 0
|
|
16
|
-
assert "
|
|
16
|
+
assert "Found" in result.stdout or "No workspaces found" in result.stdout
|
|
17
17
|
assert "Demo complete" in result.stdout
|
|
18
18
|
|
|
19
19
|
|
|
@@ -129,6 +129,30 @@ async def test_mock_planner_routes():
|
|
|
129
129
|
assert step.tool_name == "provision_link"
|
|
130
130
|
|
|
131
131
|
|
|
132
|
+
@pytest.mark.asyncio
|
|
133
|
+
async def test_mock_planner_natural_language():
|
|
134
|
+
from api_operator.adapters.mock import MockAdapter
|
|
135
|
+
|
|
136
|
+
planner = MockPlanner()
|
|
137
|
+
registry = MockAdapter().build_registry()
|
|
138
|
+
|
|
139
|
+
for phrase in (
|
|
140
|
+
"i want list of workspaces?",
|
|
141
|
+
"show me all workspaces",
|
|
142
|
+
"what workspaces do I have",
|
|
143
|
+
):
|
|
144
|
+
step = await planner.plan(phrase, registry, [], "")
|
|
145
|
+
assert step.type == "tool" and step.tool_name == "list_workspaces", phrase
|
|
146
|
+
|
|
147
|
+
step = await planner.plan("get usage for demo", registry, [], "")
|
|
148
|
+
assert step.tool_name == "get_usage"
|
|
149
|
+
assert step.tool_args["workspace_id"] == "demo"
|
|
150
|
+
|
|
151
|
+
step = await planner.plan("subscription for demo", registry, [], "")
|
|
152
|
+
assert step.tool_name == "get_subscription"
|
|
153
|
+
assert step.tool_args["workspace_id"] == "demo"
|
|
154
|
+
|
|
155
|
+
|
|
132
156
|
def test_tool_parameters_schema():
|
|
133
157
|
schema = tool_parameters_schema({"name": str, "count": int}, ["name"])
|
|
134
158
|
assert schema["properties"]["name"]["type"] == "string"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from api_operator.core.formatters import format_tool_success
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_format_list_workspaces_from_api_payload():
|
|
5
|
+
message = format_tool_success(
|
|
6
|
+
"list_workspaces",
|
|
7
|
+
{
|
|
8
|
+
"data": [
|
|
9
|
+
{
|
|
10
|
+
"id": "demo",
|
|
11
|
+
"name": "Demo Workspace",
|
|
12
|
+
"url": "http://demo.test",
|
|
13
|
+
"suspended": False,
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
assert "Found 1 workspace(s)" in message
|
|
20
|
+
assert "Demo Workspace (demo)" in message
|
|
21
|
+
assert "http://demo.test" in message
|
|
22
|
+
assert "{" not in message
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_format_list_workspaces_empty():
|
|
26
|
+
message = format_tool_success("list_workspaces", {"workspaces": [], "count": 0})
|
|
27
|
+
assert message == "No workspaces found."
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_format_create_workspace():
|
|
31
|
+
message = format_tool_success(
|
|
32
|
+
"create_workspace",
|
|
33
|
+
{"id": "acme", "name": "Acme", "url": "http://acme.test"},
|
|
34
|
+
)
|
|
35
|
+
assert "Workspace created: Acme (acme)" in message
|
|
36
|
+
assert "http://acme.test" in message
|
|
@@ -22,7 +22,7 @@ def test_available_adapters_includes_mock_and_yaml():
|
|
|
22
22
|
def test_load_mock_adapter():
|
|
23
23
|
adapter = load_adapter("mock")
|
|
24
24
|
assert adapter.name == "mock"
|
|
25
|
-
assert len(adapter.build_registry().list_tools()) ==
|
|
25
|
+
assert len(adapter.build_registry().list_tools()) == 7
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def test_load_yaml_requires_config():
|
|
@@ -90,7 +90,8 @@ async def test_help_lists_tools():
|
|
|
90
90
|
agent = build_agent("mock", settings=Settings(planner="mock"))
|
|
91
91
|
response = await agent.chat("help")
|
|
92
92
|
assert response.status == "ok"
|
|
93
|
-
assert "
|
|
93
|
+
assert "workspaces" in response.message.lower()
|
|
94
|
+
assert "assistant" in response.message.lower()
|
|
94
95
|
|
|
95
96
|
|
|
96
97
|
@pytest.mark.asyncio
|
|
@@ -60,6 +60,40 @@ async def test_yaml_tenant_host_url(tmp_path, monkeypatch):
|
|
|
60
60
|
assert captured["url"] == "http://acme.app.test/api/team/invitations"
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_yaml_connect_host_routes_via_internal_nginx(tmp_path, monkeypatch):
|
|
65
|
+
path = tmp_path / "c.yaml"
|
|
66
|
+
path.write_text(
|
|
67
|
+
"name: x\nbase_url: http://app.test\ntools:\n - name: t\n method: GET\n path: /api/workspaces\n",
|
|
68
|
+
encoding="utf-8",
|
|
69
|
+
)
|
|
70
|
+
adapter = YamlAdapter(config_path=str(path), token="tok", connect_host="nginx")
|
|
71
|
+
spec = adapter.spec.tools[0]
|
|
72
|
+
|
|
73
|
+
captured: dict[str, object] = {}
|
|
74
|
+
|
|
75
|
+
class FakeResponse:
|
|
76
|
+
status_code = 200
|
|
77
|
+
text = ""
|
|
78
|
+
def json(self): return {"data": []}
|
|
79
|
+
@property
|
|
80
|
+
def is_success(self): return True
|
|
81
|
+
|
|
82
|
+
class FakeClient:
|
|
83
|
+
async def __aenter__(self): return self
|
|
84
|
+
async def __aexit__(self, *a): pass
|
|
85
|
+
async def request(self, method, url, **kwargs):
|
|
86
|
+
captured["url"] = url
|
|
87
|
+
captured["headers"] = kwargs.get("headers", {})
|
|
88
|
+
return FakeResponse()
|
|
89
|
+
|
|
90
|
+
monkeypatch.setattr("api_operator.adapters.yaml_adapter.httpx.AsyncClient", lambda **k: FakeClient())
|
|
91
|
+
result = await adapter._execute(spec, {})
|
|
92
|
+
assert result.ok
|
|
93
|
+
assert captured["url"] == "http://nginx/api/workspaces"
|
|
94
|
+
assert captured["headers"]["Host"] == "app.test"
|
|
95
|
+
|
|
96
|
+
|
|
63
97
|
@pytest.mark.asyncio
|
|
64
98
|
async def test_yaml_missing_token(tmp_path):
|
|
65
99
|
path = tmp_path / "a.yaml"
|
|
@@ -182,4 +216,5 @@ tools:
|
|
|
182
216
|
agent = build_agent("yaml", config_path=str(yaml_path), token="t", settings=Settings(planner="mock"))
|
|
183
217
|
response = await agent.chat("list workspaces", abilities=["workspaces:read"])
|
|
184
218
|
assert response.status == "ok"
|
|
185
|
-
assert "
|
|
219
|
+
assert "Found 1 workspace" in response.message
|
|
220
|
+
assert response.tool == "list_workspaces"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|