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.
- {deepflow_voicespeed_cli-0.1.0 → deepflow_voicespeed_cli-0.1.2}/PKG-INFO +9 -2
- {deepflow_voicespeed_cli-0.1.0 → deepflow_voicespeed_cli-0.1.2}/README.md +8 -1
- {deepflow_voicespeed_cli-0.1.0 → deepflow_voicespeed_cli-0.1.2}/setup.cfg +1 -1
- {deepflow_voicespeed_cli-0.1.0 → deepflow_voicespeed_cli-0.1.2}/src/deepflow_voicespeed_cli/__init__.py +1 -1
- {deepflow_voicespeed_cli-0.1.0 → deepflow_voicespeed_cli-0.1.2}/src/deepflow_voicespeed_cli/cli.py +149 -0
- deepflow_voicespeed_cli-0.1.2/src/deepflow_voicespeed_cli/client.py +288 -0
- {deepflow_voicespeed_cli-0.1.0 → deepflow_voicespeed_cli-0.1.2}/src/deepflow_voicespeed_cli.egg-info/PKG-INFO +9 -2
- deepflow_voicespeed_cli-0.1.2/tests/test_cli.py +358 -0
- deepflow_voicespeed_cli-0.1.0/src/deepflow_voicespeed_cli/client.py +0 -142
- deepflow_voicespeed_cli-0.1.0/tests/test_cli.py +0 -114
- {deepflow_voicespeed_cli-0.1.0 → deepflow_voicespeed_cli-0.1.2}/pyproject.toml +0 -0
- {deepflow_voicespeed_cli-0.1.0 → deepflow_voicespeed_cli-0.1.2}/src/deepflow_voicespeed_cli.egg-info/SOURCES.txt +0 -0
- {deepflow_voicespeed_cli-0.1.0 → deepflow_voicespeed_cli-0.1.2}/src/deepflow_voicespeed_cli.egg-info/dependency_links.txt +0 -0
- {deepflow_voicespeed_cli-0.1.0 → deepflow_voicespeed_cli-0.1.2}/src/deepflow_voicespeed_cli.egg-info/entry_points.txt +0 -0
- {deepflow_voicespeed_cli-0.1.0 → deepflow_voicespeed_cli-0.1.2}/src/deepflow_voicespeed_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepflow-voicespeed-cli
|
|
3
|
-
Version: 0.1.
|
|
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.
|
{deepflow_voicespeed_cli-0.1.0 → deepflow_voicespeed_cli-0.1.2}/src/deepflow_voicespeed_cli/cli.py
RENAMED
|
@@ -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.
|
|
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
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|