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.
Files changed (44) hide show
  1. {api_operator-0.9.0 → api_operator-0.10.0}/CHANGELOG.md +15 -0
  2. api_operator-0.10.0/Dockerfile +12 -0
  3. {api_operator-0.9.0 → api_operator-0.10.0}/PKG-INFO +1 -1
  4. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/adapters/mock.py +36 -0
  5. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/adapters/yaml_adapter.py +37 -1
  6. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/adapters/yaml_spec.py +3 -0
  7. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/core/agent.py +2 -1
  8. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/core/config.py +1 -0
  9. api_operator-0.10.0/api_operator/core/formatters.py +169 -0
  10. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/core/planner.py +65 -6
  11. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/server/app.py +12 -1
  12. {api_operator-0.9.0 → api_operator-0.10.0}/examples/tenant-kit-adapter/adapter.yaml +10 -0
  13. {api_operator-0.9.0 → api_operator-0.10.0}/pyproject.toml +1 -1
  14. {api_operator-0.9.0 → api_operator-0.10.0}/tests/test_api.py +1 -1
  15. {api_operator-0.9.0 → api_operator-0.10.0}/tests/test_cli.py +1 -1
  16. {api_operator-0.9.0 → api_operator-0.10.0}/tests/test_core.py +24 -0
  17. api_operator-0.10.0/tests/test_formatters.py +36 -0
  18. {api_operator-0.9.0 → api_operator-0.10.0}/tests/test_mock_adapter.py +1 -1
  19. {api_operator-0.9.0 → api_operator-0.10.0}/tests/test_scenarios_e2e.py +2 -1
  20. {api_operator-0.9.0 → api_operator-0.10.0}/tests/test_yaml_adapter.py +36 -1
  21. {api_operator-0.9.0 → api_operator-0.10.0}/.env.example +0 -0
  22. {api_operator-0.9.0 → api_operator-0.10.0}/.github/workflows/tests.yml +0 -0
  23. {api_operator-0.9.0 → api_operator-0.10.0}/.gitignore +0 -0
  24. {api_operator-0.9.0 → api_operator-0.10.0}/LICENSE +0 -0
  25. {api_operator-0.9.0 → api_operator-0.10.0}/README.md +0 -0
  26. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/__init__.py +0 -0
  27. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/adapters/__init__.py +0 -0
  28. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/adapters/base.py +0 -0
  29. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/adapters/openapi_generator.py +0 -0
  30. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/adapters/registry.py +0 -0
  31. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/core/executor.py +0 -0
  32. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/core/guardrails.py +0 -0
  33. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/core/memory.py +0 -0
  34. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/core/planner_openai.py +0 -0
  35. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/factory.py +0 -0
  36. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/rag/indexer.py +0 -0
  37. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/server/cli.py +0 -0
  38. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/tools/base.py +0 -0
  39. {api_operator-0.9.0 → api_operator-0.10.0}/api_operator/tools/schema.py +0 -0
  40. {api_operator-0.9.0 → api_operator-0.10.0}/examples/tenant-kit-adapter/README.md +0 -0
  41. {api_operator-0.9.0 → api_operator-0.10.0}/scripts/integration_tenant_kit.py +0 -0
  42. {api_operator-0.9.0 → api_operator-0.10.0}/tests/__init__.py +0 -0
  43. {api_operator-0.9.0 → api_operator-0.10.0}/tests/test_agent.py +0 -0
  44. {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.9.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
- url,
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 = f"{tool_name} succeeded: {payload.get('data')}"
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,
@@ -16,3 +16,4 @@ class Settings(BaseSettings):
16
16
  openai_model: str = "gpt-4o-mini"
17
17
  require_confirm_dangerous: bool = True
18
18
  log_tool_calls: bool = True
19
+ cors_origins: str = "" # comma-separated origins; empty disables CORS middleware
@@ -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 run these tools: "
46
- f"{names}. Try: 'list workspaces', 'create workspace Acme subdomain acme', "
47
- "'invite admin@acme.com to acme', 'provision link Riyadh Jeddah 500'."
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.1.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "api-operator"
7
- version = "0.9.0"
7
+ version = "0.10.0"
8
8
  description = "Standalone AI API Operator with pluggable adapters for multi-project APIs"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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"]) == 5
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 "list_workspaces succeeded" in result.stdout
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()) == 5
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 "create_workspace" in response.message or "list workspaces" in response.message.lower()
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 "list_workspaces" in response.message
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