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,154 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ @dataclass
11
+ class ParameterSpec:
12
+ type: str = "string"
13
+ required: bool = False
14
+ default: Any = None
15
+
16
+
17
+ @dataclass
18
+ class ToolSpec:
19
+ name: str
20
+ description: str
21
+ method: str
22
+ path: str
23
+ parameters: dict[str, ParameterSpec] = field(default_factory=dict)
24
+ body: dict[str, Any] | None = None
25
+ query: dict[str, str] | None = None
26
+ dangerous: bool = False
27
+ requires_ability: str | None = None
28
+ host: str = "central" # central | tenant
29
+ tenant_param: str = "subdomain"
30
+
31
+
32
+ @dataclass
33
+ class AdapterSpec:
34
+ name: str
35
+ description: str
36
+ base_url: str
37
+ system_prompt: str
38
+ tools: list[ToolSpec]
39
+ auth_type: str = "bearer"
40
+ auth_header: str = "Authorization"
41
+ token_env: str | None = None
42
+
43
+
44
+ def load_adapter_spec(path: str | Path) -> AdapterSpec:
45
+ file_path = Path(path)
46
+ if not file_path.exists():
47
+ raise FileNotFoundError(f"Adapter config not found: {file_path}")
48
+
49
+ raw = yaml.safe_load(file_path.read_text(encoding="utf-8"))
50
+ if not isinstance(raw, dict):
51
+ raise ValueError("Adapter YAML must be a mapping")
52
+
53
+ auth = raw.get("auth") or {}
54
+ tools: list[ToolSpec] = []
55
+ for item in raw.get("tools") or []:
56
+ if not isinstance(item, dict):
57
+ continue
58
+ params: dict[str, ParameterSpec] = {}
59
+ raw_params = item.get("parameters") or {}
60
+ if isinstance(raw_params, dict):
61
+ for pname, pspec in raw_params.items():
62
+ if isinstance(pspec, dict):
63
+ params[pname] = ParameterSpec(
64
+ type=str(pspec.get("type", "string")),
65
+ required=bool(pspec.get("required", False)),
66
+ default=pspec.get("default"),
67
+ )
68
+ else:
69
+ params[pname] = ParameterSpec(required=bool(pspec))
70
+
71
+ tools.append(
72
+ ToolSpec(
73
+ name=str(item["name"]),
74
+ description=str(item.get("description", item["name"])),
75
+ method=str(item.get("method", "GET")).upper(),
76
+ path=str(item["path"]),
77
+ parameters=params,
78
+ body=item.get("body"),
79
+ query=item.get("query"),
80
+ dangerous=bool(item.get("dangerous", False)),
81
+ requires_ability=item.get("requires_ability"),
82
+ host=str(item.get("host", "central")),
83
+ tenant_param=str(item.get("tenant_param", "subdomain")),
84
+ )
85
+ )
86
+
87
+ return AdapterSpec(
88
+ name=str(raw.get("name", file_path.stem)),
89
+ description=str(raw.get("description", "")),
90
+ base_url=str(raw.get("base_url", "")).rstrip("/"),
91
+ system_prompt=str(raw.get("system_prompt") or _default_prompt(raw)),
92
+ tools=tools,
93
+ auth_type=str(auth.get("type", "bearer")),
94
+ auth_header=str(auth.get("header", "Authorization")),
95
+ token_env=auth.get("env_token"),
96
+ )
97
+
98
+
99
+ def save_adapter_spec(spec: AdapterSpec, path: str | Path) -> None:
100
+ file_path = Path(path)
101
+ payload: dict[str, Any] = {
102
+ "name": spec.name,
103
+ "description": spec.description,
104
+ "base_url": spec.base_url,
105
+ "auth": {"type": spec.auth_type, "header": spec.auth_header},
106
+ "system_prompt": spec.system_prompt,
107
+ "tools": [],
108
+ }
109
+ if spec.token_env:
110
+ payload["auth"]["env_token"] = spec.token_env
111
+
112
+ for tool in spec.tools:
113
+ entry: dict[str, Any] = {
114
+ "name": tool.name,
115
+ "description": tool.description,
116
+ "method": tool.method,
117
+ "path": tool.path,
118
+ }
119
+ if tool.host != "central":
120
+ entry["host"] = tool.host
121
+ entry["tenant_param"] = tool.tenant_param
122
+ if tool.dangerous:
123
+ entry["dangerous"] = True
124
+ if tool.requires_ability:
125
+ entry["requires_ability"] = tool.requires_ability
126
+ if tool.parameters:
127
+ entry["parameters"] = {
128
+ name: {
129
+ "type": param.type,
130
+ "required": param.required,
131
+ **({"default": param.default} if param.default is not None else {}),
132
+ }
133
+ for name, param in tool.parameters.items()
134
+ }
135
+ if tool.body:
136
+ entry["body"] = tool.body
137
+ if tool.query:
138
+ entry["query"] = tool.query
139
+ payload["tools"].append(entry)
140
+
141
+ file_path.parent.mkdir(parents=True, exist_ok=True)
142
+ file_path.write_text(
143
+ yaml.safe_dump(payload, sort_keys=False, allow_unicode=True),
144
+ encoding="utf-8",
145
+ )
146
+
147
+
148
+ def _default_prompt(raw: dict[str, Any]) -> str:
149
+ name = raw.get("name", "project")
150
+ return (
151
+ f"You are API Operator for {name}. "
152
+ "Use registered tools only. Confirm dangerous actions. "
153
+ "Reply in the user's language."
154
+ )
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from api_operator.adapters.base import Adapter
7
+ from api_operator.core.config import Settings
8
+ from api_operator.core.executor import ToolExecutor
9
+ from api_operator.core.guardrails import Guardrails
10
+ from api_operator.core.memory import Session, SessionStore
11
+ from api_operator.core.planner import Planner
12
+ from api_operator.core.planner_openai import build_planner
13
+ from api_operator.tools.base import ToolRegistry
14
+
15
+
16
+ @dataclass
17
+ class AgentResponse:
18
+ session_id: str
19
+ message: str
20
+ status: str = "ok" # ok | confirm | error
21
+ tool: str | None = None
22
+ tool_result: dict[str, Any] | None = None
23
+ metadata: dict[str, Any] = field(default_factory=dict)
24
+
25
+
26
+ class Agent:
27
+ def __init__(
28
+ self,
29
+ adapter: Adapter,
30
+ settings: Settings | None = None,
31
+ planner: Planner | None = None,
32
+ sessions: SessionStore | None = None,
33
+ ) -> None:
34
+ self.settings = settings or Settings()
35
+ self.adapter = adapter
36
+ self.registry: ToolRegistry = adapter.build_registry()
37
+ self.guardrails = Guardrails(
38
+ require_confirm_dangerous=self.settings.require_confirm_dangerous
39
+ )
40
+ self.executor = ToolExecutor(self.guardrails)
41
+ self.planner = planner or build_planner(self.settings)
42
+ self.sessions = sessions or SessionStore()
43
+
44
+ async def chat(
45
+ self,
46
+ message: str,
47
+ session_id: str | None = None,
48
+ abilities: list[str] | None = None,
49
+ auto_confirm: bool = False,
50
+ ) -> AgentResponse:
51
+ session = self.sessions.get_or_create(session_id, self.adapter.name)
52
+ session.add("user", message)
53
+
54
+ if session.pending_confirmation and not auto_confirm:
55
+ if self.guardrails.is_confirmation(message):
56
+ pending = session.pending_confirmation
57
+ session.pending_confirmation = None
58
+ result, error = await self.executor.execute(
59
+ self.registry,
60
+ pending["tool_name"],
61
+ pending["tool_args"],
62
+ abilities=abilities,
63
+ )
64
+ return self._finalize_tool(session, pending["tool_name"], result, error)
65
+
66
+ if self.guardrails.is_cancellation(message):
67
+ session.pending_confirmation = None
68
+ reply = "Cancelled. No changes were made."
69
+ session.add("assistant", reply, status="ok")
70
+ return AgentResponse(session_id=session.id, message=reply, status="ok")
71
+
72
+ plan = await self.planner.plan(
73
+ message=message,
74
+ registry=self.registry,
75
+ history=session.history(),
76
+ system_prompt=self.adapter.system_prompt(),
77
+ )
78
+
79
+ if plan.type == "respond":
80
+ session.add("assistant", plan.content, status="ok")
81
+ return AgentResponse(session_id=session.id, message=plan.content, status="ok")
82
+
83
+ if plan.type == "clarify":
84
+ session.add("assistant", plan.content, status="ok")
85
+ return AgentResponse(session_id=session.id, message=plan.content, status="ok")
86
+
87
+ if plan.type != "tool" or not plan.tool_name:
88
+ reply = "Could not determine an action."
89
+ session.add("assistant", reply, status="error")
90
+ return AgentResponse(session_id=session.id, message=reply, status="error")
91
+
92
+ tool = self.registry.get(plan.tool_name)
93
+ if tool is None:
94
+ reply = f"Tool not found: {plan.tool_name}"
95
+ session.add("assistant", reply, status="error")
96
+ return AgentResponse(session_id=session.id, message=reply, status="error")
97
+
98
+ if self.guardrails.needs_confirmation(tool) and not auto_confirm:
99
+ session.pending_confirmation = {
100
+ "tool_name": plan.tool_name,
101
+ "tool_args": plan.tool_args or {},
102
+ }
103
+ confirm_msg = self.executor.confirmation_message(tool, plan.tool_args or {})
104
+ session.add("assistant", confirm_msg, status="confirm")
105
+ return AgentResponse(
106
+ session_id=session.id,
107
+ message=confirm_msg,
108
+ status="confirm",
109
+ tool=plan.tool_name,
110
+ metadata={"arguments": plan.tool_args or {}},
111
+ )
112
+
113
+ result, error = await self.executor.execute(
114
+ self.registry,
115
+ plan.tool_name,
116
+ plan.tool_args,
117
+ abilities=abilities,
118
+ )
119
+ return self._finalize_tool(session, plan.tool_name, result, error)
120
+
121
+ def _finalize_tool(
122
+ self,
123
+ session: Session,
124
+ tool_name: str,
125
+ result: Any,
126
+ error: str | None,
127
+ ) -> AgentResponse:
128
+ if error:
129
+ session.add("assistant", error, status="error", tool=tool_name)
130
+ return AgentResponse(
131
+ session_id=session.id,
132
+ message=error,
133
+ status="error",
134
+ tool=tool_name,
135
+ )
136
+
137
+ payload = result.to_dict() if result else {"ok": False}
138
+ if payload.get("ok"):
139
+ reply = f"{tool_name} succeeded: {payload.get('data')}"
140
+ session.add("assistant", reply, status="ok", tool=tool_name)
141
+ return AgentResponse(
142
+ session_id=session.id,
143
+ message=reply,
144
+ status="ok",
145
+ tool=tool_name,
146
+ tool_result=payload,
147
+ )
148
+
149
+ err = payload.get("error", "Tool failed.")
150
+ session.add("assistant", err, status="error", tool=tool_name)
151
+ return AgentResponse(
152
+ session_id=session.id,
153
+ message=str(err),
154
+ status="error",
155
+ tool=tool_name,
156
+ tool_result=payload,
157
+ )
158
+
159
+ def list_tools(self) -> list[dict[str, Any]]:
160
+ return self.registry.schemas()
@@ -0,0 +1,18 @@
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+
4
+ class Settings(BaseSettings):
5
+ model_config = SettingsConfigDict(
6
+ env_prefix="api_operator_",
7
+ env_file=".env",
8
+ extra="ignore",
9
+ )
10
+
11
+ host: str = "127.0.0.1"
12
+ port: int = 8100
13
+ default_adapter: str = "mock"
14
+ planner: str = "auto" # auto | mock | openai
15
+ openai_api_key: str | None = None
16
+ openai_model: str = "gpt-4o-mini"
17
+ require_confirm_dangerous: bool = True
18
+ log_tool_calls: bool = True
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from api_operator.core.guardrails import Guardrails
6
+ from api_operator.tools.base import Tool, ToolRegistry, ToolResult
7
+
8
+
9
+ class ToolExecutor:
10
+ def __init__(self, guardrails: Guardrails | None = None) -> None:
11
+ self.guardrails = guardrails or Guardrails()
12
+
13
+ async def execute(
14
+ self,
15
+ registry: ToolRegistry,
16
+ tool_name: str,
17
+ arguments: dict[str, Any] | None,
18
+ abilities: list[str] | None = None,
19
+ ) -> tuple[ToolResult | None, str | None]:
20
+ tool = registry.get(tool_name)
21
+ if tool is None:
22
+ return None, f"Unknown tool: {tool_name}"
23
+
24
+ permission_error = self.guardrails.check_ability(tool, abilities)
25
+ if permission_error:
26
+ return None, permission_error
27
+
28
+ args = arguments or {}
29
+ missing = self.guardrails.missing_parameters(tool, args)
30
+ if missing:
31
+ return None, f"Missing required parameters: {', '.join(missing)}"
32
+
33
+ result = await tool.run(**args)
34
+ return result, None
35
+
36
+ def confirmation_message(self, tool: Tool, arguments: dict[str, Any]) -> str:
37
+ rendered = ", ".join(f"{k}={v!r}" for k, v in arguments.items())
38
+ return f"Confirm {tool.name}({rendered})? Reply yes to proceed or no to cancel."
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from api_operator.tools.base import Tool
6
+
7
+
8
+ class Guardrails:
9
+ def __init__(self, require_confirm_dangerous: bool = True) -> None:
10
+ self.require_confirm_dangerous = require_confirm_dangerous
11
+
12
+ def check_ability(self, tool: Tool, abilities: list[str] | None) -> str | None:
13
+ if tool.requires_ability is None:
14
+ return None
15
+ if abilities is None:
16
+ return None
17
+ if tool.requires_ability not in abilities:
18
+ return (
19
+ f"Permission denied: tool '{tool.name}' requires "
20
+ f"ability '{tool.requires_ability}'."
21
+ )
22
+ return None
23
+
24
+ def missing_parameters(self, tool: Tool, arguments: dict[str, Any]) -> list[str]:
25
+ missing: list[str] = []
26
+ for name in tool.required:
27
+ value = arguments.get(name)
28
+ if value is None or (isinstance(value, str) and not value.strip()):
29
+ missing.append(name)
30
+ return missing
31
+
32
+ def needs_confirmation(self, tool: Tool) -> bool:
33
+ return self.require_confirm_dangerous and tool.dangerous
34
+
35
+ @staticmethod
36
+ def is_confirmation(message: str) -> bool:
37
+ normalized = message.strip().lower()
38
+ return normalized in {
39
+ "yes",
40
+ "y",
41
+ "confirm",
42
+ "ok",
43
+ "okay",
44
+ "نعم",
45
+ "أكد",
46
+ "اكد",
47
+ "موافق",
48
+ }
49
+
50
+ @staticmethod
51
+ def is_cancellation(message: str) -> bool:
52
+ normalized = message.strip().lower()
53
+ return normalized in {"no", "n", "cancel", "stop", "لا", "الغ", "إلغاء", "الغاء"}
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+ from uuid import uuid4
6
+
7
+
8
+ @dataclass
9
+ class Message:
10
+ role: str
11
+ content: str
12
+ metadata: dict[str, Any] = field(default_factory=dict)
13
+
14
+
15
+ @dataclass
16
+ class Session:
17
+ id: str
18
+ adapter: str
19
+ messages: list[Message] = field(default_factory=list)
20
+ context: dict[str, Any] = field(default_factory=dict)
21
+ pending_confirmation: dict[str, Any] | None = None
22
+
23
+ def add(self, role: str, content: str, **metadata: Any) -> None:
24
+ self.messages.append(Message(role=role, content=content, metadata=metadata))
25
+
26
+ def history(self, limit: int = 20) -> list[dict[str, str]]:
27
+ return [{"role": m.role, "content": m.content} for m in self.messages[-limit:]]
28
+
29
+
30
+ class SessionStore:
31
+ def __init__(self) -> None:
32
+ self._sessions: dict[str, Session] = {}
33
+
34
+ def create(self, adapter: str, session_id: str | None = None) -> Session:
35
+ sid = session_id or str(uuid4())
36
+ session = Session(id=sid, adapter=adapter)
37
+ self._sessions[sid] = session
38
+ return session
39
+
40
+ def get(self, session_id: str) -> Session | None:
41
+ return self._sessions.get(session_id)
42
+
43
+ def get_or_create(self, session_id: str | None, adapter: str) -> Session:
44
+ if session_id and session_id in self._sessions:
45
+ return self._sessions[session_id]
46
+ return self.create(adapter=adapter, session_id=session_id)
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Protocol
5
+
6
+ from api_operator.tools.base import ToolRegistry
7
+
8
+
9
+ @dataclass
10
+ class PlanStep:
11
+ type: str # respond | tool | clarify
12
+ content: str = ""
13
+ tool_name: str | None = None
14
+ tool_args: dict[str, Any] | None = None
15
+
16
+
17
+ class Planner(Protocol):
18
+ async def plan(
19
+ self,
20
+ message: str,
21
+ registry: ToolRegistry,
22
+ history: list[dict[str, str]],
23
+ system_prompt: str,
24
+ ) -> PlanStep: ...
25
+
26
+
27
+ class MockPlanner:
28
+ """Rule-based planner for offline demos and tests (no LLM required)."""
29
+
30
+ async def plan(
31
+ self,
32
+ message: str,
33
+ registry: ToolRegistry,
34
+ history: list[dict[str, str]],
35
+ system_prompt: str,
36
+ ) -> PlanStep:
37
+ text = message.strip()
38
+ lower = text.lower()
39
+
40
+ if "help" in lower or "مساعدة" in text or lower.strip() == "tools":
41
+ names = ", ".join(registry.names())
42
+ return PlanStep(
43
+ type="respond",
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'."
48
+ ),
49
+ )
50
+
51
+ if _wants_create_workspace(lower):
52
+ args = _extract_workspace_args(text)
53
+ if args.get("name") and args.get("subdomain") and registry.get("create_workspace"):
54
+ return PlanStep(
55
+ type="tool",
56
+ tool_name="create_workspace",
57
+ tool_args=args,
58
+ )
59
+ return PlanStep(
60
+ type="clarify",
61
+ content="Please provide workspace name and subdomain (e.g. name Acme, subdomain acme).",
62
+ )
63
+
64
+ if _wants_invite(lower, text):
65
+ tool_name = "invite_member" if registry.get("invite_member") else "invite_team_member"
66
+ if registry.get(tool_name):
67
+ args = _extract_invite_args(text)
68
+ if args.get("email"):
69
+ subdomain = args.get("subdomain") or "acme"
70
+ return PlanStep(
71
+ type="tool",
72
+ tool_name=tool_name,
73
+ tool_args={
74
+ "subdomain": subdomain,
75
+ "email": args["email"],
76
+ "role": args.get("role", "member"),
77
+ },
78
+ )
79
+ return PlanStep(type="clarify", content="Which email should I invite?")
80
+
81
+ if _wants_provision_link(lower):
82
+ args = _extract_link_args(text)
83
+ if args.get("site_a") and args.get("site_b") and registry.get("provision_link"):
84
+ return PlanStep(type="tool", tool_name="provision_link", tool_args=args)
85
+
86
+ if _wants_list_connections(lower, text) and registry.get("list_connections"):
87
+ return PlanStep(type="tool", tool_name="list_connections", tool_args={})
88
+
89
+ if _wants_list_workspaces(lower, text) and registry.get("list_workspaces"):
90
+ return PlanStep(type="tool", tool_name="list_workspaces", tool_args={})
91
+
92
+ return PlanStep(
93
+ type="respond",
94
+ content=(
95
+ "I did not map that request to a tool. Say 'help' to see examples, or be explicit "
96
+ "(e.g. 'list workspaces', 'create workspace Acme subdomain acme')."
97
+ ),
98
+ )
99
+
100
+
101
+ def _wants_create_workspace(lower: str) -> bool:
102
+ return (
103
+ "create workspace" in lower
104
+ or "new workspace" in lower
105
+ or "أنشئ workspace" in lower
106
+ or "انشئ workspace" in lower
107
+ )
108
+
109
+
110
+ def _wants_invite(lower: str, text: str) -> bool:
111
+ return "invite" in lower or "ادع" in lower or "دعوة" in text
112
+
113
+
114
+ def _wants_provision_link(lower: str) -> bool:
115
+ return "provision link" in lower or ("provision" in lower and "link" in lower)
116
+
117
+
118
+ def _wants_list_connections(lower: str, text: str) -> bool:
119
+ if not ("connection" in lower or "link" in lower or "اتصال" in text or "رابط" in text):
120
+ return False
121
+ return any(word in lower for word in ("list", "show", "display")) or "اعرض" in text
122
+
123
+
124
+ def _wants_list_workspaces(lower: str, text: str) -> bool:
125
+ if "workspace" not in lower and "workspace" not in text:
126
+ return False
127
+ if _wants_create_workspace(lower):
128
+ return False
129
+ return (
130
+ "list workspace" in lower
131
+ or "list workspaces" in lower
132
+ or "show workspace" in lower
133
+ or "show workspaces" in lower
134
+ or lower.strip() == "workspaces"
135
+ or "ورّيني" in text
136
+ or "اعرض" in text
137
+ )
138
+
139
+
140
+ def _extract_workspace_args(text: str) -> dict[str, Any]:
141
+ args: dict[str, Any] = {}
142
+ lower = text.lower()
143
+ if "subdomain" in lower:
144
+ parts = lower.split("subdomain", 1)[1].strip().split()
145
+ if parts:
146
+ args["subdomain"] = parts[0].strip(",.")
147
+ if "name" in lower:
148
+ segment = text.split("name", 1)[1]
149
+ if "subdomain" in segment.lower():
150
+ segment = segment.split("subdomain", 1)[0]
151
+ args["name"] = segment.strip().strip(",.")
152
+ tokens = text.replace(",", " ").split()
153
+ for idx, token in enumerate(tokens):
154
+ if token.lower() == "workspace" and idx + 1 < len(tokens):
155
+ maybe_name = tokens[idx + 1]
156
+ if maybe_name.lower() not in {"subdomain", "named", "on"}:
157
+ args.setdefault("name", maybe_name)
158
+ if token.lower() == "subdomain" and idx + 1 < len(tokens):
159
+ args["subdomain"] = tokens[idx + 1].strip(".,")
160
+ return args
161
+
162
+
163
+ def _extract_invite_args(text: str) -> dict[str, Any]:
164
+ args: dict[str, Any] = {}
165
+ for token in text.replace(",", " ").split():
166
+ if "@" in token:
167
+ args["email"] = token.strip(".,")
168
+ lower = text.lower()
169
+ if " to " in lower:
170
+ subdomain = lower.split(" to ", 1)[1].strip().split()[0]
171
+ args["subdomain"] = subdomain.strip(".,")
172
+ for token in text.split():
173
+ if token.lower() in {"admin", "member"}:
174
+ args["role"] = token.lower()
175
+ return args
176
+
177
+
178
+ def _extract_link_args(text: str) -> dict[str, Any]:
179
+ args: dict[str, Any] = {}
180
+ lower = text.lower()
181
+ if "provision" in lower and "link" in lower:
182
+ tail = text.lower().split("link", 1)[1].strip()
183
+ parts = tail.split()
184
+ if len(parts) >= 2:
185
+ args["site_a"] = parts[0].capitalize()
186
+ args["site_b"] = parts[1].capitalize()
187
+ if len(parts) >= 3 and parts[2].isdigit():
188
+ args["bandwidth_mbps"] = int(parts[2])
189
+ return args