pipecatcloud 0.0.6__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,274 @@
1
+ import asyncio
2
+ import itertools
3
+ import webbrowser
4
+ from contextlib import asynccontextmanager
5
+ from typing import AsyncGenerator, Optional, Tuple
6
+
7
+ import aiohttp
8
+ import typer
9
+ from loguru import logger
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+
13
+ from pipecatcloud._utils.async_utils import synchronize_api, synchronizer
14
+ from pipecatcloud._utils.auth_utils import requires_login
15
+ from pipecatcloud._utils.http_utils import construct_api_url
16
+ from pipecatcloud.cli import PANEL_TITLE_ERROR, PANEL_TITLE_SUCCESS
17
+ from pipecatcloud.config import _remove_user_config, _store_user_config, config, user_config_path
18
+
19
+ auth_cli = typer.Typer(name="auth", help="Manage Pipecat Cloud credentials.", no_args_is_help=True)
20
+
21
+
22
+ class _AuthFlow:
23
+ def __init__(self):
24
+ pass
25
+
26
+ @asynccontextmanager
27
+ async def start(self) -> AsyncGenerator[Tuple[Optional[str], Optional[str]], None]:
28
+ async with aiohttp.ClientSession() as session:
29
+ try:
30
+ async with session.post(f"{construct_api_url('login_path')}") as resp:
31
+ if resp.status != 200:
32
+ raise Exception(f"Failed to start auth flow: {resp.status}")
33
+ data = await resp.json()
34
+ self.token_flow_id = data["token_flow_id"]
35
+ self.wait_secret = data["wait_secret"]
36
+ web_url = data["web_url"]
37
+
38
+ yield (self.token_flow_id, web_url)
39
+ except Exception:
40
+ pass
41
+ yield (None, None)
42
+
43
+ async def finish(self, timeout: float = 40.0, network_timeout: float = 5.0) -> Optional[str]:
44
+ start_time = asyncio.get_event_loop().time()
45
+ async with aiohttp.ClientSession() as session:
46
+ while (asyncio.get_event_loop().time() - start_time) < timeout:
47
+ try:
48
+ async with session.get(
49
+ f"{config.get('server_url')}{config.get('login_status_path')}",
50
+ params={
51
+ "token_flow_id": self.token_flow_id,
52
+ "wait_secret": self.wait_secret
53
+ },
54
+ timeout=aiohttp.ClientTimeout(total=timeout + network_timeout),
55
+ ) as resp:
56
+ if resp.status == 200:
57
+ data = await resp.json()
58
+ if data["status"] == "complete":
59
+ return data["token"]
60
+ if data["status"] == "failure":
61
+ return "failure"
62
+ await asyncio.sleep(2)
63
+ continue
64
+ except (asyncio.TimeoutError, aiohttp.ClientError):
65
+ continue
66
+ return None
67
+
68
+
69
+ AuthFlow = synchronize_api(_AuthFlow)
70
+
71
+
72
+ def _open_url(url: str) -> bool:
73
+ try:
74
+ browser = webbrowser.get()
75
+ if isinstance(browser, webbrowser.GenericBrowser) and browser.name != "open":
76
+ return False
77
+ else:
78
+ return browser.open_new_tab(url)
79
+ except webbrowser.Error:
80
+ return False
81
+
82
+
83
+ async def _set_credentials(
84
+ token: str,
85
+ account_org: str,
86
+ ):
87
+ console = Console()
88
+ with console.status("Storing user credentials", spinner="dots"):
89
+ _store_user_config(token, account_org)
90
+
91
+
92
+ async def _get_account_org(
93
+ token: str, active_org: Optional[str] = None) -> Optional[Tuple[str, str]]:
94
+ console = Console()
95
+ # Retrieve account organization
96
+ with console.status("[dim]Obtaining account organization data[/dim]", spinner="dots"):
97
+ async with aiohttp.ClientSession() as session:
98
+ async with session.get(
99
+ f"{construct_api_url('organization_path')}",
100
+ headers={"Authorization": f"Bearer {token}"},
101
+ ) as resp:
102
+ if resp.status == 200:
103
+ data = await resp.json()
104
+ organizations = data["organizations"]
105
+
106
+ # If active_org is specified, try to find it in the list
107
+ if active_org:
108
+ for org in organizations:
109
+ if org["name"] == active_org:
110
+ return org["name"], org["verboseName"]
111
+
112
+ # Default to first organization if active_org not found or not specified
113
+ if organizations:
114
+ return organizations[0]["name"], organizations[0]["verboseName"]
115
+
116
+ return None
117
+ else:
118
+ raise Exception(f"Failed to retrieve account organization: {resp.status}")
119
+
120
+
121
+ # ----- Login
122
+
123
+ async def _login(active_org: Optional[str] = None):
124
+ console = Console()
125
+ auth_flow = _AuthFlow()
126
+
127
+ try:
128
+ async with auth_flow.start() as (token_flow_id, web_url):
129
+ if web_url is None:
130
+ console.print(
131
+ Panel(
132
+ "Unable to connect to Pipecat Cloud API. Please check your network connection and try again.",
133
+ title=f"[red]{PANEL_TITLE_ERROR}[/red]",
134
+ title_align="left",
135
+ border_style="red",
136
+ ))
137
+ return
138
+
139
+ with console.status("Waiting for authentication in the web browser", spinner="dots"):
140
+ # Open the web url in the browser
141
+ if _open_url(web_url):
142
+ console.print(
143
+ Panel(
144
+ "The web browser should have opened for you to authenticate with Pipecat Cloud.\n"
145
+ "If it didn't, please copy this URL into your web browser manually:\n\n"
146
+ f"[blue][link={web_url}]{web_url}[/link][/blue]\n", ))
147
+ else:
148
+ console.print(Panel(
149
+ "[red]Was not able to launch web browser[/red]",
150
+ title=f"[red]{PANEL_TITLE_ERROR}[/red]",
151
+ title_align="left",
152
+ border_style="red",
153
+ ))
154
+
155
+ with console.status("Waiting for token flow to complete...", spinner="dots") as status:
156
+ for attempt in itertools.count():
157
+ result = await auth_flow.finish()
158
+ if result is not None:
159
+ break
160
+ status.update(f"Waiting for token flow to complete... (attempt {attempt + 1})")
161
+ if result is None:
162
+ console.print(Panel(
163
+ "[red]Authentication failed[/red]",
164
+ title=f"[red]{PANEL_TITLE_ERROR}[/red]",
165
+ title_align="left",
166
+ border_style="red",
167
+ ))
168
+ return
169
+
170
+ # Retrieve user namespace
171
+ if not active_org:
172
+ try:
173
+ account_name, account_name_verbose = await _get_account_org(result)
174
+ logger.debug(f"Setting namespace to {account_name_verbose}")
175
+ if account_name is None:
176
+ raise
177
+ except Exception:
178
+ console.print(
179
+ Panel(
180
+ "[red]Account has no associated namespace. Have you completed the onboarding process? Please first sign in via the web dashboard (https://dashboard.pipecat.cloud).[/red]",
181
+ title=f"[red]{PANEL_TITLE_ERROR}[/red]",
182
+ title_align="left",
183
+ border_style="red",
184
+ ))
185
+ return
186
+ else:
187
+ account_name = active_org
188
+
189
+ console.print(Panel(
190
+ "[green]Web authentication finished successfully![/green]\n"
191
+ f"Account details stored to [magenta]{user_config_path}[/magenta]",
192
+ title=f"[green]{PANEL_TITLE_SUCCESS}[/green]",
193
+ title_align="left",
194
+ border_style="green",
195
+ ))
196
+ await _set_credentials(result, account_name)
197
+ except Exception:
198
+ pass
199
+
200
+
201
+ @auth_cli.command(name="login", help="Login to Pipecat Cloud and get a new token")
202
+ @synchronizer.create_blocking
203
+ async def login(ctx: typer.Context):
204
+ active_org = ctx.obj["org"]
205
+ await _login(active_org)
206
+
207
+
208
+ # ----- Logut
209
+
210
+ @auth_cli.command(name="logout", help="Logout from Pipecat Cloud")
211
+ @synchronizer.create_blocking
212
+ @requires_login
213
+ async def logout():
214
+ """
215
+ # This command should delete the session token
216
+ # due to networkless token verification, the token will not be invalidated
217
+ # it will just be removed from the local config file
218
+ # if we want token invalidation, we'll need to either use sign-in tokens with clerk
219
+ # or use a networked token verification system of our own design
220
+ """
221
+ console = Console()
222
+
223
+ with console.status("Removing user ID", spinner="dots"):
224
+ _remove_user_config()
225
+
226
+ console.print(Panel(
227
+ "You are now logged out of Pipecat Cloud.",
228
+ title=f"[yellow]{PANEL_TITLE_SUCCESS}[/yellow]",
229
+ title_align="left",
230
+ border_style="yellow",
231
+ ))
232
+
233
+
234
+ @auth_cli.command(name="whoami", help="Display data about the current user.")
235
+ @synchronizer.create_blocking
236
+ @requires_login
237
+ async def whomai():
238
+ console = Console()
239
+ token = config.get("token")
240
+ active_org = config.get("org")
241
+
242
+ async with aiohttp.ClientSession() as session:
243
+ try:
244
+ # Retrieve user data from whoami endpoint
245
+ with console.status("[dim]Obtaining user data[/dim]", spinner="dots"):
246
+ async with session.get(
247
+ f"{construct_api_url('whoami_path')}",
248
+ headers={"Authorization": f"Bearer {token}"},
249
+ ) as resp:
250
+ if resp.status == 200:
251
+ data = await resp.json()
252
+ else:
253
+ raise
254
+
255
+ # Retrieve default user organization
256
+ account_name, account_name_verbose = await _get_account_org(token, active_org)
257
+ if account_name is None:
258
+ raise
259
+
260
+ console.print(Panel(
261
+ f"[bold]User ID:[/bold] {data['user']['userId']}\n"
262
+ f"[bold]Active Organization:[/bold] {account_name_verbose} [dim]({account_name})[/dim]",
263
+ title="whoami",
264
+ title_align="left",
265
+ border_style="dim",
266
+ ))
267
+ except Exception:
268
+ console.print(Panel(
269
+ "[red]Failed to get user data. Please contact support.[/red]",
270
+ title=f"[red]{PANEL_TITLE_ERROR}[/red]",
271
+ title_align="left",
272
+ border_style="red",
273
+ ))
274
+ return
@@ -0,0 +1,230 @@
1
+ import asyncio
2
+ from typing import Optional
3
+
4
+ import aiohttp
5
+ import typer
6
+ from rich.console import Console, Group
7
+ from rich.panel import Panel
8
+ from rich.table import Table
9
+
10
+ from pipecatcloud._utils.async_utils import synchronizer
11
+ from pipecatcloud._utils.auth_utils import requires_login
12
+ from pipecatcloud._utils.deploy_utils import load_deploy_config_file
13
+ from pipecatcloud._utils.http_utils import construct_api_url
14
+ from pipecatcloud.cli import PANEL_TITLE_ERROR, PANEL_TITLE_SUCCESS
15
+
16
+ console = Console()
17
+
18
+ TEST_CONFIG_FILE = {}
19
+
20
+
21
+ async def _poll_deployment_status(
22
+ token: str,
23
+ organization: str,
24
+ agent_id: str,
25
+ max_attempts: int = 10):
26
+
27
+ attempts = 0
28
+ while attempts < max_attempts:
29
+ with console.status(f"Monitoring status for deployment id: [bold]'{agent_id}'[/bold] (attempt {attempts + 1} of {max_attempts})", spinner="dots"):
30
+ async with aiohttp.ClientSession() as session:
31
+ response = await session.get(
32
+ f"{construct_api_url('services_path').format(org=organization)}/{agent_id}",
33
+ headers={"Authorization": f"Bearer {token}"},
34
+ )
35
+ data = await response.json()
36
+ if data["body"]["ready"]:
37
+ console.print(Panel(
38
+ f"[green]Agent '{agent_id}' is ready[/green]",
39
+ title=f"[green]{PANEL_TITLE_SUCCESS}[/green]",
40
+ title_align="left",
41
+ border_style="green",
42
+ ))
43
+ break
44
+ """
45
+ if not data["body"]["ready"]:
46
+ console.print(Panel(
47
+ f"[red]Unable to deploy '{agent_id}'[/red]\n\n"
48
+ f"[dim]Message from API:[/dim]\n{data['body']['conditions'][-1]['message']}",
49
+ title=f"[red]{PANEL_TITLE_ERROR}[/red]",
50
+ title_align="left",
51
+ border_style="red",
52
+ ))
53
+ break
54
+ """
55
+ await asyncio.sleep(2)
56
+ attempts += 1
57
+
58
+
59
+ async def _deploy(
60
+ token: str,
61
+ agent_name: str,
62
+ image: str,
63
+ deployment_config: dict,
64
+ credentials: Optional[str]):
65
+ organization = deployment_config.get("organization")
66
+ if not organization or not token:
67
+ console.print("[red]Not logged in[/red]")
68
+ return
69
+
70
+ try:
71
+ with console.status(f"Deploying [bold]'{agent_name}'[/bold]", spinner="dots"):
72
+ async with aiohttp.ClientSession() as session:
73
+ request_url = f"{construct_api_url('services_path').format(org=organization)}"
74
+ # Build request payload
75
+ payload = {
76
+ "serviceName": agent_name,
77
+ "image": image,
78
+ }
79
+ if deployment_config.get("secrets"):
80
+ payload["secretSet"] = deployment_config["secrets"]
81
+
82
+ if credentials:
83
+ payload["imagePullSecretSet"] = credentials
84
+
85
+ async with session.put(
86
+ request_url,
87
+ headers={"Authorization": f"Bearer {token}"},
88
+ json=payload,
89
+ ) as resp:
90
+ resp.raise_for_status()
91
+ except Exception as e:
92
+ console.print(Panel(
93
+ f"[red]Unable to deploy '{agent_name}'. {e}[/red]",
94
+ title=f"[red]{PANEL_TITLE_ERROR}[/red]",
95
+ title_align="left",
96
+ border_style="red",
97
+ ))
98
+ return typer.Exit(1)
99
+
100
+ # Poll the deployment status until it's ready
101
+ await _poll_deployment_status(token, organization, agent_name)
102
+
103
+ # ----- Deploy
104
+
105
+
106
+ def create_deploy_command(app: typer.Typer):
107
+ # Note we wrap the deploy command to avoid circular imports
108
+ @app.command(name="deploy", help="Deploy to Pipecat Cloud")
109
+ @synchronizer.create_blocking
110
+ @requires_login
111
+ async def deploy(
112
+ ctx: typer.Context,
113
+ agent_name: str = typer.Argument(
114
+ None,
115
+ help="Name of the agent to deploy e.g. 'my-agent'",
116
+ show_default=False),
117
+ image: str = typer.Argument(
118
+ None,
119
+ help="Docker image location e.g. 'my-image:latest'",
120
+ show_default=False),
121
+ min_instances: int = typer.Option(
122
+ 1,
123
+ "--min-instances",
124
+ help="Minimum number of instances to keep warm",
125
+ rich_help_panel="Deployment Configuration",
126
+ min=1),
127
+ max_instances: int = typer.Option(
128
+ 20,
129
+ "--max-instances",
130
+ help="Maximum number of allowed instances",
131
+ rich_help_panel="Deployment Configuration",
132
+ min=0,
133
+ max=50),
134
+ secrets: str = typer.Option(
135
+ None,
136
+ "--secrets",
137
+ "--s",
138
+ help="Secret set to use for deployment",
139
+ rich_help_panel="Deployment Configuration",
140
+ ),
141
+ organization: str = typer.Option(
142
+ None,
143
+ "--organization",
144
+ "--org",
145
+ help="Organization to deploy to",
146
+ rich_help_panel="Deployment Configuration",
147
+ ),
148
+ credentials: str = typer.Option(
149
+ None,
150
+ "--credentials",
151
+ "--c",
152
+ help="Image pull secret to use for deployment",
153
+ rich_help_panel="Deployment Configuration",
154
+ ),
155
+ ):
156
+ token = ctx.obj["token"]
157
+
158
+ # Compose deployment config from CLI options and config file (if provided)
159
+ # Order of precedence:
160
+ # 1. Arguments provided to the CLI deploy command
161
+ # 2. Values from the config toml file
162
+ # 3. CLI command defaults
163
+ deployment_config = {
164
+ "min_instances": min_instances or 1,
165
+ "max_instances": max_instances or 20,
166
+ "organization": organization or ctx.obj.get("org"),
167
+ "secrets": secrets,
168
+ }
169
+
170
+ # Collect passed values from CLI arguments (ignoring defaults)
171
+ passed_values = {}
172
+ for param in ctx.command.params:
173
+ if param.name == "agent_name" or param.name == "image":
174
+ continue
175
+ value = ctx.params.get(str(param.name))
176
+ # Only include if the value is different from the parameter's default
177
+ if value != param.default:
178
+ passed_values[param.name] = value
179
+ deployment_config.update(passed_values)
180
+
181
+ # Merge with values from deployment config file
182
+ if deploy_config := load_deploy_config_file():
183
+ for key, value in deploy_config.items():
184
+ deployment_config.setdefault(key, value)
185
+
186
+ final_agent_name = agent_name or deployment_config.get("agent_name")
187
+ final_image = image or deployment_config.get("image")
188
+
189
+ # Assert agent name and image are provided
190
+ if not final_agent_name:
191
+ raise typer.BadParameter("Agent name is required")
192
+ if not final_image:
193
+ console.print("[red]Error:[/red] Image location is required", style="bold red")
194
+ raise typer.BadParameter("Image location is required")
195
+
196
+ # Create and display table
197
+ table = Table(
198
+ show_header=False,
199
+ border_style="dim",
200
+ show_edge=True,
201
+ show_lines=True)
202
+ table.add_column("Property", style="cyan")
203
+ table.add_column("Value", style="green")
204
+ table.add_row("Min instances", str(deployment_config['min_instances']))
205
+ table.add_row("Max instances", str(deployment_config['max_instances']))
206
+
207
+ content = Group(
208
+ (f"[bold white]Agent name:[/bold white] [green]{final_agent_name}[/green]"),
209
+ (f"[bold white]Image:[/bold white] [green]{final_image}[/green]"),
210
+ (f"[bold white]Organization:[/bold white] [green]{deployment_config['organization']}[/green]"),
211
+ (f"[bold white]Secret set:[/bold white] [green]{secrets}[/green]"),
212
+ (f"[bold white]Image pull secret:[/bold white] [green]{credentials}[/green]"),
213
+ "\n[dim]Deployment configuration:[/dim]",
214
+ table)
215
+
216
+ console.print(
217
+ Panel(
218
+ content,
219
+ title="Review deployment",
220
+ title_align="left",
221
+ padding=1,
222
+ style="yellow",
223
+ border_style="yellow"))
224
+
225
+ if not typer.confirm("\nDo you want to proceed with deployment?", default=True):
226
+ raise typer.Abort()
227
+
228
+ # Deploy method posts the deployment config to the API
229
+ # and polls the deployment status until it's ready
230
+ await _deploy(token, final_agent_name, final_image, deployment_config, credentials)
@@ -0,0 +1,83 @@
1
+ import sys
2
+
3
+ import typer
4
+ from loguru import logger
5
+
6
+ from pipecatcloud.cli.agent import agent_cli
7
+ from pipecatcloud.cli.auth import auth_cli
8
+ from pipecatcloud.cli.deploy import create_deploy_command
9
+ from pipecatcloud.cli.organizations import organization_cli
10
+ from pipecatcloud.cli.run import create_run_command
11
+ from pipecatcloud.cli.secrets import secrets_cli
12
+ from pipecatcloud.config import config
13
+
14
+ logger.remove()
15
+ logger.add(sys.stderr, level=str(config.get("cli_log_level", "INFO")))
16
+
17
+
18
+ def version_callback(value: bool):
19
+ if value:
20
+ from pipecatcloud.__version__ import version
21
+
22
+ typer.echo(
23
+ f"ᓚᘏᗢ Pipecat Cloud Client Version: {typer.style(version, fg=typer.colors.GREEN)}")
24
+ raise typer.Exit()
25
+
26
+
27
+ def config_callback(value: bool):
28
+ if value:
29
+ from rich import print_json
30
+ from rich.pretty import pprint
31
+
32
+ from pipecatcloud._utils.deploy_utils import load_deploy_config_file
33
+ from pipecatcloud.config import config
34
+
35
+ # Check for deploy config
36
+ deploy_config = load_deploy_config_file()
37
+ if deploy_config:
38
+ print("Deploy config:")
39
+ print_json(data=deploy_config)
40
+
41
+ print("Config:")
42
+ print_json(data=config.to_dict())
43
+
44
+ pprint(config.to_dict())
45
+ raise typer.Exit()
46
+
47
+
48
+ entrypoint_cli_typer = typer.Typer(
49
+ no_args_is_help=True,
50
+ add_completion=False,
51
+ rich_markup_mode="markdown",
52
+ help="""
53
+ ᓚᘏᗢ Pipecat Cloud CLI
54
+ See website at https://pipecat.cloud
55
+ """,
56
+ )
57
+
58
+
59
+ @entrypoint_cli_typer.callback()
60
+ def pipecat(
61
+ ctx: typer.Context,
62
+ _version: bool = typer.Option(None, "--version", callback=version_callback, help="CLI version"),
63
+ _config: bool = typer.Option(None, "--config", callback=config_callback, help="CLI config"),
64
+ ):
65
+ if not ctx.obj:
66
+ ctx.obj = {}
67
+
68
+ # All commands require an active namespace (organization)
69
+ # The CLI reads config data and sets the users currently active org in context
70
+ # which is used as a default when an `--org` flag is not provided
71
+ ctx.obj["org"] = config.get("org")
72
+ ctx.obj["token"] = config.get("token")
73
+ ctx.obj["default_public_key"] = config.get("default_public_key", None)
74
+ ctx.obj["default_public_key_name"] = config.get("default_public_key_name", None)
75
+
76
+
77
+ create_deploy_command(entrypoint_cli_typer)
78
+ create_run_command(entrypoint_cli_typer)
79
+ entrypoint_cli_typer.add_typer(auth_cli, rich_help_panel="Commands")
80
+ entrypoint_cli_typer.add_typer(organization_cli, rich_help_panel="Commands")
81
+ entrypoint_cli_typer.add_typer(agent_cli, rich_help_panel="Commands")
82
+ entrypoint_cli_typer.add_typer(secrets_cli, rich_help_panel="Commands")
83
+ entrypoint_cli = typer.main.get_command(entrypoint_cli_typer)
File without changes