deepflow-voicespeed-cli 0.1.0__tar.gz → 0.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepflow-voicespeed-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Command line client for VoiceSpeed OpenAPI
5
5
  Home-page: https://github.com/DeepFlowAI/voicespeed
6
6
  License: Proprietary
@@ -36,7 +36,14 @@ voicespeed agents update 1 --name "Support Pro"
36
36
  voicespeed agents disable 1
37
37
  voicespeed agents enable 1
38
38
  voicespeed agents basic-settings 1
39
- voicespeed conversation run 1 --message "Hello" --mode text2text
39
+ voicespeed conversation run 1 --message "Hello" --mode text2text \
40
+ --variables '{"customer_name":"Ada"}'
41
+ voicespeed calls preset-variables 1 call_42 \
42
+ --variables '{"customer_name":"Ada","order_no":"A1024"}'
43
+ voicespeed realtime-sessions create 1 --external-id call_42 \
44
+ --variables '{"customer_name":"Ada"}'
40
45
  ```
41
46
 
47
+ `--variables` is a JSON object of text values. It is optional on `conversation run` and `realtime-sessions create`. `calls preset-variables` stores values for a FlowKit call before it starts; the call id is the same value later sent as `session_id`.
48
+
42
49
  All successful commands print formatted JSON.
@@ -26,7 +26,14 @@ voicespeed agents update 1 --name "Support Pro"
26
26
  voicespeed agents disable 1
27
27
  voicespeed agents enable 1
28
28
  voicespeed agents basic-settings 1
29
- voicespeed conversation run 1 --message "Hello" --mode text2text
29
+ voicespeed conversation run 1 --message "Hello" --mode text2text \
30
+ --variables '{"customer_name":"Ada"}'
31
+ voicespeed calls preset-variables 1 call_42 \
32
+ --variables '{"customer_name":"Ada","order_no":"A1024"}'
33
+ voicespeed realtime-sessions create 1 --external-id call_42 \
34
+ --variables '{"customer_name":"Ada"}'
30
35
  ```
31
36
 
37
+ `--variables` is a JSON object of text values. It is optional on `conversation run` and `realtime-sessions create`. `calls preset-variables` stores values for a FlowKit call before it starts; the call id is the same value later sent as `session_id`.
38
+
32
39
  All successful commands print formatted JSON.
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = deepflow-voicespeed-cli
3
- version = 0.1.0
3
+ version = 0.1.2
4
4
  description = Command line client for VoiceSpeed OpenAPI
5
5
  long_description = file: README.md
6
6
  long_description_content_type = text/markdown
@@ -1,3 +1,3 @@
1
1
  """VoiceSpeed OpenAPI command line client."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.2"
@@ -31,6 +31,9 @@ def build_parser() -> argparse.ArgumentParser:
31
31
  subparsers = parser.add_subparsers(dest="resource", required=True)
32
32
  _add_agent_commands(subparsers)
33
33
  _add_conversation_commands(subparsers)
34
+ _add_call_commands(subparsers)
35
+ _add_realtime_session_commands(subparsers)
36
+ _add_fine_tune_commands(subparsers)
34
37
  return parser
35
38
 
36
39
 
@@ -79,9 +82,78 @@ def _add_conversation_commands(subparsers: argparse._SubParsersAction) -> None:
79
82
  run_parser.add_argument("agent_id", type=int)
80
83
  run_parser.add_argument("--message", required=True)
81
84
  run_parser.add_argument("--mode", choices=["text2text", "text2voice"], default="text2text")
85
+ run_parser.add_argument(
86
+ "--variables",
87
+ help='Call variables as a JSON object, e.g. \'{"customer_name":"Ada"}\'',
88
+ )
82
89
  run_parser.set_defaults(handler=_handle_conversation_run)
83
90
 
84
91
 
92
+ def _add_call_commands(subparsers: argparse._SubParsersAction) -> None:
93
+ calls = subparsers.add_parser("calls", help="Manage a call before it starts")
94
+ actions = calls.add_subparsers(dest="action", required=True)
95
+
96
+ preset_parser = actions.add_parser(
97
+ "preset-variables",
98
+ help="Store call variables before the call starts",
99
+ )
100
+ preset_parser.add_argument("agent_id", type=int)
101
+ preset_parser.add_argument("call_id")
102
+ preset_parser.add_argument(
103
+ "--variables",
104
+ required=True,
105
+ help='Call variables as a JSON object, e.g. \'{"customer_name":"Ada"}\'',
106
+ )
107
+ preset_parser.set_defaults(handler=_handle_calls_preset_variables)
108
+
109
+
110
+ def _add_realtime_session_commands(subparsers: argparse._SubParsersAction) -> None:
111
+ sessions = subparsers.add_parser(
112
+ "realtime-sessions",
113
+ help="Mint a production realtime voice session",
114
+ )
115
+ actions = sessions.add_subparsers(dest="action", required=True)
116
+
117
+ create_parser = actions.add_parser("create", help="Create a realtime session token")
118
+ create_parser.add_argument("agent_id", type=int)
119
+ create_parser.add_argument("--external-id", required=True)
120
+ create_parser.add_argument("--trace-id")
121
+ create_parser.add_argument(
122
+ "--variables",
123
+ help='Call variables as a JSON object, e.g. \'{"customer_name":"Ada"}\'',
124
+ )
125
+ create_parser.set_defaults(handler=_handle_realtime_session_create)
126
+
127
+
128
+ def _add_fine_tune_commands(subparsers: argparse._SubParsersAction) -> None:
129
+ fine_tune = subparsers.add_parser("fine-tune", help="Manage fine-tune jobs")
130
+ actions = fine_tune.add_subparsers(dest="action", required=True)
131
+
132
+ list_parser = actions.add_parser("list", help="List fine-tune jobs")
133
+ list_parser.add_argument("agent_id", type=int)
134
+ list_parser.add_argument("--status")
135
+ list_parser.add_argument("--page", type=int, default=1)
136
+ list_parser.add_argument("--per-page", type=int, default=10)
137
+ list_parser.set_defaults(handler=_handle_fine_tune_list)
138
+
139
+ get_parser = actions.add_parser("get", help="Get a fine-tune job")
140
+ get_parser.add_argument("agent_id", type=int)
141
+ get_parser.add_argument("job_id", type=int)
142
+ get_parser.set_defaults(handler=_handle_fine_tune_get)
143
+
144
+ create_parser = actions.add_parser("create", help="Create a fine-tune job")
145
+ create_parser.add_argument("agent_id", type=int)
146
+ create_parser.add_argument("--name", required=True)
147
+ create_parser.add_argument("--file", required=True, help="Path to JSONL training file")
148
+ create_parser.add_argument("--n-epochs", type=int, default=3)
149
+ create_parser.add_argument("--model-suffix")
150
+ create_parser.add_argument(
151
+ "--train-params",
152
+ help="Advanced training params as JSON string",
153
+ )
154
+ create_parser.set_defaults(handler=_handle_fine_tune_create)
155
+
156
+
85
157
  def main(argv: Sequence[str] | None = None) -> int:
86
158
  parser = build_parser()
87
159
  args = parser.parse_args(argv)
@@ -174,6 +246,83 @@ def _handle_conversation_run(
174
246
  args.agent_id,
175
247
  message=args.message,
176
248
  mode=args.mode,
249
+ variables=_parse_variables(args.variables),
250
+ )
251
+
252
+
253
+ def _handle_calls_preset_variables(
254
+ client: VoiceSpeedClient,
255
+ args: argparse.Namespace,
256
+ ) -> Any:
257
+ return client.preset_call_variables(
258
+ args.agent_id,
259
+ args.call_id,
260
+ variables=_parse_variables(args.variables) or {},
261
+ )
262
+
263
+
264
+ def _handle_realtime_session_create(
265
+ client: VoiceSpeedClient,
266
+ args: argparse.Namespace,
267
+ ) -> Any:
268
+ return client.create_realtime_session(
269
+ args.agent_id,
270
+ external_id=args.external_id,
271
+ trace_id=args.trace_id,
272
+ variables=_parse_variables(args.variables),
273
+ )
274
+
275
+
276
+ def _parse_variables(raw: str | None) -> dict[str, str] | None:
277
+ if raw is None:
278
+ return None
279
+ try:
280
+ value = json.loads(raw)
281
+ except json.JSONDecodeError as exc:
282
+ raise CliError("variables must be valid JSON") from exc
283
+ if not isinstance(value, dict) or any(not isinstance(item, str) for item in value.values()):
284
+ raise CliError("variables must be a JSON object of text values")
285
+ return value
286
+
287
+
288
+ def _handle_fine_tune_list(
289
+ client: VoiceSpeedClient,
290
+ args: argparse.Namespace,
291
+ ) -> Any:
292
+ return client.list_fine_tune_jobs(
293
+ args.agent_id,
294
+ status=args.status,
295
+ page=args.page,
296
+ per_page=args.per_page,
297
+ )
298
+
299
+
300
+ def _handle_fine_tune_get(
301
+ client: VoiceSpeedClient,
302
+ args: argparse.Namespace,
303
+ ) -> Any:
304
+ return client.get_fine_tune_job(args.agent_id, args.job_id)
305
+
306
+
307
+ def _handle_fine_tune_create(
308
+ client: VoiceSpeedClient,
309
+ args: argparse.Namespace,
310
+ ) -> Any:
311
+ train_params = None
312
+ if args.train_params:
313
+ try:
314
+ train_params = json.loads(args.train_params)
315
+ except json.JSONDecodeError as exc:
316
+ raise CliError("train_params must be valid JSON") from exc
317
+ if not isinstance(train_params, dict):
318
+ raise CliError("train_params must be a JSON object")
319
+ return client.create_fine_tune_job(
320
+ args.agent_id,
321
+ name=args.name,
322
+ file_path=args.file,
323
+ n_epochs=args.n_epochs,
324
+ model_suffix=args.model_suffix,
325
+ train_params=train_params,
177
326
  )
178
327
 
179
328
 
@@ -0,0 +1,288 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+ from urllib.error import HTTPError, URLError
7
+ from urllib.parse import quote, urlencode
8
+ from urllib.request import Request, urlopen
9
+ from uuid import uuid4
10
+
11
+
12
+ class VoiceSpeedClientError(Exception):
13
+ """Base error raised by the VoiceSpeed client."""
14
+
15
+
16
+ class VoiceSpeedApiError(VoiceSpeedClientError):
17
+ def __init__(self, status_code: int, body: str) -> None:
18
+ self.status_code = status_code
19
+ self.body = body
20
+ super().__init__(f"HTTP {status_code}: {body}")
21
+
22
+
23
+ class VoiceSpeedRequestError(VoiceSpeedClientError):
24
+ pass
25
+
26
+
27
+ @dataclass
28
+ class VoiceSpeedClient:
29
+ base_url: str
30
+ api_key: str
31
+ timeout: float = 30.0
32
+
33
+ def __post_init__(self) -> None:
34
+ self.base_url = self.base_url.rstrip("/")
35
+ self.api_key = self.api_key.strip()
36
+ if not self.api_key:
37
+ raise ValueError("Missing API key")
38
+
39
+ def request(
40
+ self,
41
+ method: str,
42
+ path: str,
43
+ *,
44
+ query: dict[str, Any] | None = None,
45
+ payload: dict[str, Any] | None = None,
46
+ ) -> Any:
47
+ url = f"{self.base_url}/api/v1/openapi{path}"
48
+ clean_query = {
49
+ key: value for key, value in (query or {}).items() if value is not None
50
+ }
51
+ if clean_query:
52
+ url = f"{url}?{urlencode(clean_query)}"
53
+
54
+ body = None
55
+ headers = {
56
+ "Accept": "application/json",
57
+ "Authorization": f"Bearer {self.api_key}",
58
+ }
59
+ if payload is not None:
60
+ body = json.dumps(payload).encode("utf-8")
61
+ headers["Content-Type"] = "application/json"
62
+
63
+ req = Request(url, data=body, headers=headers, method=method.upper())
64
+ try:
65
+ with urlopen(req, timeout=self.timeout) as resp:
66
+ return self._parse_response(resp.read())
67
+ except HTTPError as exc:
68
+ raise VoiceSpeedApiError(exc.code, self._read_error_body(exc)) from exc
69
+ except URLError as exc:
70
+ raise VoiceSpeedRequestError(str(exc.reason)) from exc
71
+ except OSError as exc:
72
+ raise VoiceSpeedRequestError(str(exc)) from exc
73
+
74
+ def list_agents(
75
+ self,
76
+ *,
77
+ status: str | None = None,
78
+ page: int = 1,
79
+ per_page: int = 20,
80
+ ) -> Any:
81
+ return self.request(
82
+ "GET",
83
+ "/agents",
84
+ query={"status": status, "page": page, "per_page": per_page},
85
+ )
86
+
87
+ def create_agent(self, *, name: str, description: str | None = None) -> Any:
88
+ return self.request(
89
+ "POST",
90
+ "/agents",
91
+ payload={"name": name, "description": description},
92
+ )
93
+
94
+ def update_agent(
95
+ self,
96
+ agent_id: int,
97
+ *,
98
+ name: str | None = None,
99
+ description: str | None = None,
100
+ ) -> Any:
101
+ payload = {
102
+ key: value
103
+ for key, value in {"name": name, "description": description}.items()
104
+ if value is not None
105
+ }
106
+ return self.request("PATCH", f"/agents/{agent_id}", payload=payload)
107
+
108
+ def set_agent_enabled(self, agent_id: int, *, enabled: bool) -> Any:
109
+ action = "enable" if enabled else "disable"
110
+ return self.request("POST", f"/agents/{agent_id}/{action}")
111
+
112
+ def get_basic_settings(self, agent_id: int) -> Any:
113
+ return self.request("GET", f"/agents/{agent_id}/basic-settings")
114
+
115
+ def run_conversation(
116
+ self,
117
+ agent_id: int,
118
+ *,
119
+ message: str,
120
+ mode: str = "text2text",
121
+ variables: dict[str, str] | None = None,
122
+ ) -> Any:
123
+ payload: dict[str, Any] = {"message": message, "mode": mode}
124
+ if variables is not None:
125
+ payload["variables"] = variables
126
+ return self.request(
127
+ "POST",
128
+ f"/agents/{agent_id}/conversation",
129
+ payload=payload,
130
+ )
131
+
132
+ def preset_call_variables(
133
+ self,
134
+ agent_id: int,
135
+ call_id: str,
136
+ *,
137
+ variables: dict[str, str],
138
+ ) -> Any:
139
+ encoded_call_id = quote(call_id, safe="")
140
+ return self.request(
141
+ "PUT",
142
+ f"/agents/{agent_id}/calls/{encoded_call_id}/variables",
143
+ payload={"variables": variables},
144
+ )
145
+
146
+ def create_realtime_session(
147
+ self,
148
+ agent_id: int,
149
+ *,
150
+ external_id: str,
151
+ trace_id: str | None = None,
152
+ variables: dict[str, str] | None = None,
153
+ ) -> Any:
154
+ payload: dict[str, Any] = {"external_id": external_id}
155
+ if trace_id is not None:
156
+ payload["trace_id"] = trace_id
157
+ if variables is not None:
158
+ payload["variables"] = variables
159
+ return self.request(
160
+ "POST",
161
+ f"/agents/{agent_id}/realtime-sessions",
162
+ payload=payload,
163
+ )
164
+
165
+ def list_fine_tune_jobs(
166
+ self,
167
+ agent_id: int,
168
+ *,
169
+ status: str | None = None,
170
+ page: int = 1,
171
+ per_page: int = 10,
172
+ ) -> Any:
173
+ return self.request(
174
+ "GET",
175
+ f"/agents/{agent_id}/fine-tune-jobs",
176
+ query={"status": status, "page": page, "per_page": per_page},
177
+ )
178
+
179
+ def get_fine_tune_job(self, agent_id: int, job_id: int) -> Any:
180
+ return self.request("GET", f"/agents/{agent_id}/fine-tune-jobs/{job_id}")
181
+
182
+ def create_fine_tune_job(
183
+ self,
184
+ agent_id: int,
185
+ *,
186
+ name: str,
187
+ file_path: str,
188
+ n_epochs: int = 3,
189
+ model_suffix: str | None = None,
190
+ train_params: dict[str, Any] | None = None,
191
+ timeout: float | None = None,
192
+ ) -> Any:
193
+ fields: dict[str, str] = {
194
+ "name": name,
195
+ "n_epochs": str(n_epochs),
196
+ }
197
+ if model_suffix:
198
+ fields["model_suffix"] = model_suffix
199
+ if train_params:
200
+ fields["train_params"] = json.dumps(train_params, ensure_ascii=False)
201
+
202
+ with open(file_path, "rb") as fh:
203
+ file_bytes = fh.read()
204
+ filename = file_path.rsplit("/", 1)[-1]
205
+ return self.request_multipart(
206
+ "POST",
207
+ f"/agents/{agent_id}/fine-tune-jobs",
208
+ fields=fields,
209
+ files={"file": (filename, file_bytes, "application/jsonl")},
210
+ timeout=timeout if timeout is not None else 600.0,
211
+ )
212
+
213
+ def request_multipart(
214
+ self,
215
+ method: str,
216
+ path: str,
217
+ *,
218
+ fields: dict[str, str],
219
+ files: dict[str, tuple[str, bytes, str]],
220
+ timeout: float | None = None,
221
+ ) -> Any:
222
+ url = f"{self.base_url}/api/v1/openapi{path}"
223
+ body, content_type = _encode_multipart(fields, files)
224
+ headers = {
225
+ "Accept": "application/json",
226
+ "Authorization": f"Bearer {self.api_key}",
227
+ "Content-Type": content_type,
228
+ }
229
+ req = Request(url, data=body, headers=headers, method=method.upper())
230
+ effective_timeout = self.timeout if timeout is None else timeout
231
+ try:
232
+ with urlopen(req, timeout=effective_timeout) as resp:
233
+ return self._parse_response(resp.read())
234
+ except HTTPError as exc:
235
+ raise VoiceSpeedApiError(exc.code, self._read_error_body(exc)) from exc
236
+ except URLError as exc:
237
+ raise VoiceSpeedRequestError(str(exc.reason)) from exc
238
+ except OSError as exc:
239
+ raise VoiceSpeedRequestError(str(exc)) from exc
240
+
241
+ @staticmethod
242
+ def _parse_response(data: bytes) -> Any:
243
+ if not data:
244
+ return None
245
+ text = data.decode("utf-8")
246
+ try:
247
+ return json.loads(text)
248
+ except json.JSONDecodeError:
249
+ return text
250
+
251
+ @staticmethod
252
+ def _read_error_body(exc: HTTPError) -> str:
253
+ body = exc.read()
254
+ if not body:
255
+ return exc.reason
256
+ return body.decode("utf-8", errors="replace")
257
+
258
+
259
+ def _encode_multipart(
260
+ fields: dict[str, str],
261
+ files: dict[str, tuple[str, bytes, str]],
262
+ ) -> tuple[bytes, str]:
263
+ boundary = uuid4().hex
264
+ chunks: list[bytes] = []
265
+ for key, value in fields.items():
266
+ chunks.extend(
267
+ [
268
+ f"--{boundary}\r\n".encode(),
269
+ f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode(),
270
+ value.encode(),
271
+ b"\r\n",
272
+ ]
273
+ )
274
+ for key, (filename, data, content_type) in files.items():
275
+ chunks.extend(
276
+ [
277
+ f"--{boundary}\r\n".encode(),
278
+ (
279
+ f'Content-Disposition: form-data; name="{key}"; '
280
+ f'filename="{filename}"\r\n'
281
+ ).encode(),
282
+ f"Content-Type: {content_type}\r\n\r\n".encode(),
283
+ data,
284
+ b"\r\n",
285
+ ]
286
+ )
287
+ chunks.append(f"--{boundary}--\r\n".encode())
288
+ return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepflow-voicespeed-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Command line client for VoiceSpeed OpenAPI
5
5
  Home-page: https://github.com/DeepFlowAI/voicespeed
6
6
  License: Proprietary
@@ -36,7 +36,14 @@ voicespeed agents update 1 --name "Support Pro"
36
36
  voicespeed agents disable 1
37
37
  voicespeed agents enable 1
38
38
  voicespeed agents basic-settings 1
39
- voicespeed conversation run 1 --message "Hello" --mode text2text
39
+ voicespeed conversation run 1 --message "Hello" --mode text2text \
40
+ --variables '{"customer_name":"Ada"}'
41
+ voicespeed calls preset-variables 1 call_42 \
42
+ --variables '{"customer_name":"Ada","order_no":"A1024"}'
43
+ voicespeed realtime-sessions create 1 --external-id call_42 \
44
+ --variables '{"customer_name":"Ada"}'
40
45
  ```
41
46
 
47
+ `--variables` is a JSON object of text values. It is optional on `conversation run` and `realtime-sessions create`. `calls preset-variables` stores values for a FlowKit call before it starts; the call id is the same value later sent as `session_id`.
48
+
42
49
  All successful commands print formatted JSON.
@@ -0,0 +1,358 @@
1
+ import json
2
+ from unittest.mock import Mock, patch
3
+
4
+ from deepflow_voicespeed_cli import cli
5
+ from deepflow_voicespeed_cli.client import VoiceSpeedApiError, VoiceSpeedClient
6
+
7
+
8
+ def test_agents_list_calls_client_and_prints_json(capsys):
9
+ client = Mock()
10
+ client.list_agents.return_value = {"items": [{"id": 1, "name": "Support"}]}
11
+
12
+ with patch("deepflow_voicespeed_cli.cli.VoiceSpeedClient", return_value=client):
13
+ code = cli.main(
14
+ [
15
+ "--api-key",
16
+ "vsk_test",
17
+ "agents",
18
+ "list",
19
+ "--status",
20
+ "enabled",
21
+ ]
22
+ )
23
+
24
+ assert code == 0
25
+ client.list_agents.assert_called_once_with(status="enabled", page=1, per_page=20)
26
+ assert json.loads(capsys.readouterr().out)["items"][0]["name"] == "Support"
27
+
28
+
29
+ def test_missing_api_key_returns_error(monkeypatch, capsys):
30
+ monkeypatch.delenv("VOICESPEED_API_KEY", raising=False)
31
+
32
+ code = cli.main(["agents", "list"])
33
+
34
+ assert code == 1
35
+ assert "Missing API key" in capsys.readouterr().err
36
+
37
+
38
+ def test_update_requires_at_least_one_field(capsys):
39
+ code = cli.main(["--api-key", "vsk_test", "agents", "update", "1"])
40
+
41
+ assert code == 1
42
+ assert "At least one" in capsys.readouterr().err
43
+
44
+
45
+ def test_http_error_returns_nonzero(capsys):
46
+ client = Mock()
47
+ client.list_agents.side_effect = VoiceSpeedApiError(403, '{"code":"FORBIDDEN"}')
48
+
49
+ with patch("deepflow_voicespeed_cli.cli.VoiceSpeedClient", return_value=client):
50
+ code = cli.main(["--api-key", "vsk_test", "agents", "list"])
51
+
52
+ assert code == 1
53
+ assert "HTTP 403" in capsys.readouterr().err
54
+
55
+
56
+ def test_client_builds_json_request():
57
+ captured = {}
58
+
59
+ class FakeResponse:
60
+ def __enter__(self):
61
+ return self
62
+
63
+ def __exit__(self, _exc_type, _exc, _tb):
64
+ return None
65
+
66
+ def read(self):
67
+ return b'{"id": 1, "name": "Support"}'
68
+
69
+ def fake_urlopen(req, timeout):
70
+ captured["req"] = req
71
+ captured["timeout"] = timeout
72
+ return FakeResponse()
73
+
74
+ with patch("deepflow_voicespeed_cli.client.urlopen", fake_urlopen):
75
+ client = VoiceSpeedClient("http://example.com/", "vsk_test", timeout=12)
76
+ result = client.create_agent(name="Support", description="Demo")
77
+
78
+ req = captured["req"]
79
+ assert result["id"] == 1
80
+ assert captured["timeout"] == 12
81
+ assert req.get_method() == "POST"
82
+ assert req.full_url == "http://example.com/api/v1/openapi/agents"
83
+ assert req.headers["Authorization"] == "Bearer vsk_test"
84
+ assert json.loads(req.data.decode("utf-8")) == {
85
+ "name": "Support",
86
+ "description": "Demo",
87
+ }
88
+
89
+
90
+ def test_conversation_run_command():
91
+ client = Mock()
92
+ client.run_conversation.return_value = {"reply": "hi"}
93
+
94
+ with patch("deepflow_voicespeed_cli.cli.VoiceSpeedClient", return_value=client):
95
+ code = cli.main(
96
+ [
97
+ "--api-key",
98
+ "vsk_test",
99
+ "conversation",
100
+ "run",
101
+ "3",
102
+ "--message",
103
+ "hello",
104
+ "--mode",
105
+ "text2voice",
106
+ ]
107
+ )
108
+
109
+ assert code == 0
110
+ client.run_conversation.assert_called_once_with(
111
+ 3,
112
+ message="hello",
113
+ mode="text2voice",
114
+ variables=None,
115
+ )
116
+
117
+
118
+ def test_conversation_run_passes_variables():
119
+ client = Mock()
120
+ client.run_conversation.return_value = {"reply": "hi"}
121
+
122
+ with patch("deepflow_voicespeed_cli.cli.VoiceSpeedClient", return_value=client):
123
+ code = cli.main(
124
+ [
125
+ "--api-key",
126
+ "vsk_test",
127
+ "conversation",
128
+ "run",
129
+ "3",
130
+ "--message",
131
+ "hello",
132
+ "--variables",
133
+ '{"customer_name":"Ada"}',
134
+ ]
135
+ )
136
+
137
+ assert code == 0
138
+ client.run_conversation.assert_called_once_with(
139
+ 3,
140
+ message="hello",
141
+ mode="text2text",
142
+ variables={"customer_name": "Ada"},
143
+ )
144
+
145
+
146
+ def test_variables_must_be_text_object(capsys):
147
+ code = cli.main(
148
+ [
149
+ "--api-key",
150
+ "vsk_test",
151
+ "conversation",
152
+ "run",
153
+ "3",
154
+ "--message",
155
+ "hello",
156
+ "--variables",
157
+ '{"customer_name":1}',
158
+ ]
159
+ )
160
+
161
+ assert code == 1
162
+ assert "text values" in capsys.readouterr().err
163
+
164
+
165
+ def test_calls_preset_variables_command():
166
+ client = Mock()
167
+ client.preset_call_variables.return_value = {"call_id": "call/42", "expires_at": "t"}
168
+
169
+ with patch("deepflow_voicespeed_cli.cli.VoiceSpeedClient", return_value=client):
170
+ code = cli.main(
171
+ [
172
+ "--api-key",
173
+ "vsk_test",
174
+ "calls",
175
+ "preset-variables",
176
+ "3",
177
+ "call/42",
178
+ "--variables",
179
+ '{"customer_name":"Ada"}',
180
+ ]
181
+ )
182
+
183
+ assert code == 0
184
+ client.preset_call_variables.assert_called_once_with(
185
+ 3,
186
+ "call/42",
187
+ variables={"customer_name": "Ada"},
188
+ )
189
+
190
+
191
+ def test_realtime_session_create_command():
192
+ client = Mock()
193
+ client.create_realtime_session.return_value = {"session_id": "call_42", "token": "t"}
194
+
195
+ with patch("deepflow_voicespeed_cli.cli.VoiceSpeedClient", return_value=client):
196
+ code = cli.main(
197
+ [
198
+ "--api-key",
199
+ "vsk_test",
200
+ "realtime-sessions",
201
+ "create",
202
+ "3",
203
+ "--external-id",
204
+ "call_42",
205
+ "--trace-id",
206
+ "abc",
207
+ "--variables",
208
+ '{"customer_name":"Ada"}',
209
+ ]
210
+ )
211
+
212
+ assert code == 0
213
+ client.create_realtime_session.assert_called_once_with(
214
+ 3,
215
+ external_id="call_42",
216
+ trace_id="abc",
217
+ variables={"customer_name": "Ada"},
218
+ )
219
+
220
+
221
+ def test_fine_tune_list_command():
222
+ client = Mock()
223
+ client.list_fine_tune_jobs.return_value = {"items": [{"id": 12, "status": "running"}]}
224
+
225
+ with patch("deepflow_voicespeed_cli.cli.VoiceSpeedClient", return_value=client):
226
+ code = cli.main(
227
+ [
228
+ "--api-key",
229
+ "vsk_test",
230
+ "fine-tune",
231
+ "list",
232
+ "1",
233
+ "--status",
234
+ "running",
235
+ ]
236
+ )
237
+
238
+ assert code == 0
239
+ client.list_fine_tune_jobs.assert_called_once_with(
240
+ 1,
241
+ status="running",
242
+ page=1,
243
+ per_page=10,
244
+ )
245
+
246
+
247
+ def test_fine_tune_create_command(tmp_path):
248
+ train_file = tmp_path / "train.jsonl"
249
+ train_file.write_text('{"messages":[]}\n', encoding="utf-8")
250
+ client = Mock()
251
+ client.create_fine_tune_job.return_value = {"id": 12, "status": "uploading"}
252
+
253
+ with patch("deepflow_voicespeed_cli.cli.VoiceSpeedClient", return_value=client):
254
+ code = cli.main(
255
+ [
256
+ "--api-key",
257
+ "vsk_test",
258
+ "fine-tune",
259
+ "create",
260
+ "1",
261
+ "--name",
262
+ "Support FT",
263
+ "--file",
264
+ str(train_file),
265
+ "--train-params",
266
+ '{"lora_r": 16}',
267
+ ]
268
+ )
269
+
270
+ assert code == 0
271
+ client.create_fine_tune_job.assert_called_once_with(
272
+ 1,
273
+ name="Support FT",
274
+ file_path=str(train_file),
275
+ n_epochs=3,
276
+ model_suffix=None,
277
+ train_params={"lora_r": 16},
278
+ )
279
+
280
+
281
+ def test_client_create_fine_tune_job_uses_multipart(tmp_path):
282
+ train_file = tmp_path / "train.jsonl"
283
+ train_file.write_text('{"messages":[]}\n', encoding="utf-8")
284
+ captured = {}
285
+
286
+ class FakeResponse:
287
+ def __enter__(self):
288
+ return self
289
+
290
+ def __exit__(self, _exc_type, _exc, _tb):
291
+ return None
292
+
293
+ def read(self):
294
+ return b'{"id": 12, "status": "uploading"}'
295
+
296
+ def fake_urlopen(req, timeout):
297
+ captured["req"] = req
298
+ captured["timeout"] = timeout
299
+ return FakeResponse()
300
+
301
+ with patch("deepflow_voicespeed_cli.client.urlopen", fake_urlopen):
302
+ client = VoiceSpeedClient("http://example.com/", "vsk_test")
303
+ result = client.create_fine_tune_job(
304
+ 1,
305
+ name="Support FT",
306
+ file_path=str(train_file),
307
+ train_params={"lora_r": 16},
308
+ )
309
+
310
+ req = captured["req"]
311
+ assert result["id"] == 12
312
+ assert captured["timeout"] == 600.0
313
+ assert req.get_method() == "POST"
314
+ assert req.full_url == "http://example.com/api/v1/openapi/agents/1/fine-tune-jobs"
315
+ assert req.headers["Authorization"] == "Bearer vsk_test"
316
+ assert "multipart/form-data" in req.headers["Content-type"]
317
+ body = req.data.decode("utf-8")
318
+ assert 'name="name"' in body
319
+ assert "Support FT" in body
320
+ assert 'filename="train.jsonl"' in body
321
+
322
+
323
+ def test_client_sends_call_variables():
324
+ captured = []
325
+
326
+ class FakeResponse:
327
+ def __enter__(self):
328
+ return self
329
+
330
+ def __exit__(self, _exc_type, _exc, _tb):
331
+ return None
332
+
333
+ def read(self):
334
+ return b"{}"
335
+
336
+ def fake_urlopen(req, timeout):
337
+ captured.append(req)
338
+ return FakeResponse()
339
+
340
+ with patch("deepflow_voicespeed_cli.client.urlopen", fake_urlopen):
341
+ client = VoiceSpeedClient("http://example.com/", "vsk_test")
342
+ client.run_conversation(3, message="hello", variables={"customer_name": "Ada"})
343
+ client.preset_call_variables(3, "call/42", variables={"customer_name": "Ada"})
344
+ client.create_realtime_session(3, external_id="call_42", variables={"order_no": "A1"})
345
+
346
+ conversation, preset, realtime = captured
347
+ assert conversation.full_url == "http://example.com/api/v1/openapi/agents/3/conversation"
348
+ assert json.loads(conversation.data.decode("utf-8"))["variables"] == {"customer_name": "Ada"}
349
+ assert preset.get_method() == "PUT"
350
+ assert preset.full_url == (
351
+ "http://example.com/api/v1/openapi/agents/3/calls/call%2F42/variables"
352
+ )
353
+ assert realtime.get_method() == "POST"
354
+ assert realtime.full_url == "http://example.com/api/v1/openapi/agents/3/realtime-sessions"
355
+ assert json.loads(realtime.data.decode("utf-8")) == {
356
+ "external_id": "call_42",
357
+ "variables": {"order_no": "A1"},
358
+ }
@@ -1,142 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- from dataclasses import dataclass
5
- from typing import Any
6
- from urllib.error import HTTPError, URLError
7
- from urllib.parse import urlencode
8
- from urllib.request import Request, urlopen
9
-
10
-
11
- class VoiceSpeedClientError(Exception):
12
- """Base error raised by the VoiceSpeed client."""
13
-
14
-
15
- class VoiceSpeedApiError(VoiceSpeedClientError):
16
- def __init__(self, status_code: int, body: str) -> None:
17
- self.status_code = status_code
18
- self.body = body
19
- super().__init__(f"HTTP {status_code}: {body}")
20
-
21
-
22
- class VoiceSpeedRequestError(VoiceSpeedClientError):
23
- pass
24
-
25
-
26
- @dataclass
27
- class VoiceSpeedClient:
28
- base_url: str
29
- api_key: str
30
- timeout: float = 30.0
31
-
32
- def __post_init__(self) -> None:
33
- self.base_url = self.base_url.rstrip("/")
34
- self.api_key = self.api_key.strip()
35
- if not self.api_key:
36
- raise ValueError("Missing API key")
37
-
38
- def request(
39
- self,
40
- method: str,
41
- path: str,
42
- *,
43
- query: dict[str, Any] | None = None,
44
- payload: dict[str, Any] | None = None,
45
- ) -> Any:
46
- url = f"{self.base_url}/api/v1/openapi{path}"
47
- clean_query = {
48
- key: value for key, value in (query or {}).items() if value is not None
49
- }
50
- if clean_query:
51
- url = f"{url}?{urlencode(clean_query)}"
52
-
53
- body = None
54
- headers = {
55
- "Accept": "application/json",
56
- "Authorization": f"Bearer {self.api_key}",
57
- }
58
- if payload is not None:
59
- body = json.dumps(payload).encode("utf-8")
60
- headers["Content-Type"] = "application/json"
61
-
62
- req = Request(url, data=body, headers=headers, method=method.upper())
63
- try:
64
- with urlopen(req, timeout=self.timeout) as resp:
65
- return self._parse_response(resp.read())
66
- except HTTPError as exc:
67
- raise VoiceSpeedApiError(exc.code, self._read_error_body(exc)) from exc
68
- except URLError as exc:
69
- raise VoiceSpeedRequestError(str(exc.reason)) from exc
70
- except OSError as exc:
71
- raise VoiceSpeedRequestError(str(exc)) from exc
72
-
73
- def list_agents(
74
- self,
75
- *,
76
- status: str | None = None,
77
- page: int = 1,
78
- per_page: int = 20,
79
- ) -> Any:
80
- return self.request(
81
- "GET",
82
- "/agents",
83
- query={"status": status, "page": page, "per_page": per_page},
84
- )
85
-
86
- def create_agent(self, *, name: str, description: str | None = None) -> Any:
87
- return self.request(
88
- "POST",
89
- "/agents",
90
- payload={"name": name, "description": description},
91
- )
92
-
93
- def update_agent(
94
- self,
95
- agent_id: int,
96
- *,
97
- name: str | None = None,
98
- description: str | None = None,
99
- ) -> Any:
100
- payload = {
101
- key: value
102
- for key, value in {"name": name, "description": description}.items()
103
- if value is not None
104
- }
105
- return self.request("PATCH", f"/agents/{agent_id}", payload=payload)
106
-
107
- def set_agent_enabled(self, agent_id: int, *, enabled: bool) -> Any:
108
- action = "enable" if enabled else "disable"
109
- return self.request("POST", f"/agents/{agent_id}/{action}")
110
-
111
- def get_basic_settings(self, agent_id: int) -> Any:
112
- return self.request("GET", f"/agents/{agent_id}/basic-settings")
113
-
114
- def run_conversation(
115
- self,
116
- agent_id: int,
117
- *,
118
- message: str,
119
- mode: str = "text2text",
120
- ) -> Any:
121
- return self.request(
122
- "POST",
123
- f"/agents/{agent_id}/conversation",
124
- payload={"message": message, "mode": mode},
125
- )
126
-
127
- @staticmethod
128
- def _parse_response(data: bytes) -> Any:
129
- if not data:
130
- return None
131
- text = data.decode("utf-8")
132
- try:
133
- return json.loads(text)
134
- except json.JSONDecodeError:
135
- return text
136
-
137
- @staticmethod
138
- def _read_error_body(exc: HTTPError) -> str:
139
- body = exc.read()
140
- if not body:
141
- return exc.reason
142
- return body.decode("utf-8", errors="replace")
@@ -1,114 +0,0 @@
1
- import json
2
- from unittest.mock import Mock, patch
3
-
4
- from deepflow_voicespeed_cli import cli
5
- from deepflow_voicespeed_cli.client import VoiceSpeedApiError, VoiceSpeedClient
6
-
7
-
8
- def test_agents_list_calls_client_and_prints_json(capsys):
9
- client = Mock()
10
- client.list_agents.return_value = {"items": [{"id": 1, "name": "Support"}]}
11
-
12
- with patch("deepflow_voicespeed_cli.cli.VoiceSpeedClient", return_value=client):
13
- code = cli.main(
14
- [
15
- "--api-key",
16
- "vsk_test",
17
- "agents",
18
- "list",
19
- "--status",
20
- "enabled",
21
- ]
22
- )
23
-
24
- assert code == 0
25
- client.list_agents.assert_called_once_with(status="enabled", page=1, per_page=20)
26
- assert json.loads(capsys.readouterr().out)["items"][0]["name"] == "Support"
27
-
28
-
29
- def test_missing_api_key_returns_error(monkeypatch, capsys):
30
- monkeypatch.delenv("VOICESPEED_API_KEY", raising=False)
31
-
32
- code = cli.main(["agents", "list"])
33
-
34
- assert code == 1
35
- assert "Missing API key" in capsys.readouterr().err
36
-
37
-
38
- def test_update_requires_at_least_one_field(capsys):
39
- code = cli.main(["--api-key", "vsk_test", "agents", "update", "1"])
40
-
41
- assert code == 1
42
- assert "At least one" in capsys.readouterr().err
43
-
44
-
45
- def test_http_error_returns_nonzero(capsys):
46
- client = Mock()
47
- client.list_agents.side_effect = VoiceSpeedApiError(403, '{"code":"FORBIDDEN"}')
48
-
49
- with patch("deepflow_voicespeed_cli.cli.VoiceSpeedClient", return_value=client):
50
- code = cli.main(["--api-key", "vsk_test", "agents", "list"])
51
-
52
- assert code == 1
53
- assert "HTTP 403" in capsys.readouterr().err
54
-
55
-
56
- def test_client_builds_json_request():
57
- captured = {}
58
-
59
- class FakeResponse:
60
- def __enter__(self):
61
- return self
62
-
63
- def __exit__(self, _exc_type, _exc, _tb):
64
- return None
65
-
66
- def read(self):
67
- return b'{"id": 1, "name": "Support"}'
68
-
69
- def fake_urlopen(req, timeout):
70
- captured["req"] = req
71
- captured["timeout"] = timeout
72
- return FakeResponse()
73
-
74
- with patch("deepflow_voicespeed_cli.client.urlopen", fake_urlopen):
75
- client = VoiceSpeedClient("http://example.com/", "vsk_test", timeout=12)
76
- result = client.create_agent(name="Support", description="Demo")
77
-
78
- req = captured["req"]
79
- assert result["id"] == 1
80
- assert captured["timeout"] == 12
81
- assert req.get_method() == "POST"
82
- assert req.full_url == "http://example.com/api/v1/openapi/agents"
83
- assert req.headers["Authorization"] == "Bearer vsk_test"
84
- assert json.loads(req.data.decode("utf-8")) == {
85
- "name": "Support",
86
- "description": "Demo",
87
- }
88
-
89
-
90
- def test_conversation_run_command():
91
- client = Mock()
92
- client.run_conversation.return_value = {"reply": "hi"}
93
-
94
- with patch("deepflow_voicespeed_cli.cli.VoiceSpeedClient", return_value=client):
95
- code = cli.main(
96
- [
97
- "--api-key",
98
- "vsk_test",
99
- "conversation",
100
- "run",
101
- "3",
102
- "--message",
103
- "hello",
104
- "--mode",
105
- "text2voice",
106
- ]
107
- )
108
-
109
- assert code == 0
110
- client.run_conversation.assert_called_once_with(
111
- 3,
112
- message="hello",
113
- mode="text2voice",
114
- )