pipecatcloud 1.1.0__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/CHANGELOG.md +120 -0
  2. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/PKG-INFO +1 -1
  3. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/_utils/deploy_utils.py +326 -0
  4. pipecatcloud-1.2.0/src/pipecatcloud/_utils/github_utils.py +129 -0
  5. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/_utils/regions.py +21 -0
  6. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/api.py +408 -5
  7. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/agent.py +311 -2
  8. pipecatcloud-1.2.0/src/pipecatcloud/cli/commands/agent_profiles.py +232 -0
  9. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/build.py +26 -1
  10. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/deploy.py +199 -7
  11. pipecatcloud-1.2.0/src/pipecatcloud/cli/commands/github.py +307 -0
  12. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/organizations.py +2 -0
  13. pipecatcloud-1.2.0/src/pipecatcloud/cli/commands/regions.py +301 -0
  14. pipecatcloud-1.2.0/src/pipecatcloud/cli/commands/registry_keys.py +161 -0
  15. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/secrets.py +86 -14
  16. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/entry_point.py +7 -0
  17. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/config.py +12 -0
  18. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud.egg-info/PKG-INFO +1 -1
  19. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud.egg-info/SOURCES.txt +12 -0
  20. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud.egg-info/scm_file_list.json +67 -55
  21. pipecatcloud-1.2.0/src/pipecatcloud.egg-info/scm_version.json +8 -0
  22. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/conftest.py +12 -0
  23. pipecatcloud-1.2.0/tests/test_agent_profiles_and_resources.py +281 -0
  24. pipecatcloud-1.2.0/tests/test_agent_status_output.py +64 -0
  25. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_api_client.py +62 -0
  26. pipecatcloud-1.2.0/tests/test_deploy_architecture.py +63 -0
  27. pipecatcloud-1.2.0/tests/test_deploy_referenced_secret_set.py +118 -0
  28. pipecatcloud-1.2.0/tests/test_github_integration.py +934 -0
  29. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_regions.py +3 -1
  30. pipecatcloud-1.2.0/tests/test_regions_lifecycle.py +274 -0
  31. pipecatcloud-1.2.0/tests/test_secrets_reference.py +91 -0
  32. pipecatcloud-1.2.0/tests/test_secrets_set_referenced.py +120 -0
  33. pipecatcloud-1.1.0/src/pipecatcloud/cli/commands/regions.py +0 -52
  34. pipecatcloud-1.1.0/src/pipecatcloud.egg-info/scm_version.json +0 -8
  35. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/.claude/settings.json +0 -0
  36. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/.github/workflows/format.yml +0 -0
  37. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/.github/workflows/publish-pypi.yml +0 -0
  38. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/.github/workflows/publish-test.yml +0 -0
  39. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/.github/workflows/python-compatibility.yml +0 -0
  40. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/.github/workflows/tests.yml +0 -0
  41. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/.gitignore +0 -0
  42. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/.pre-commit-config.yaml +0 -0
  43. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/CLAUDE.md +0 -0
  44. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/LICENSE +0 -0
  45. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/README.md +0 -0
  46. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/pipecat-cloud.png +0 -0
  47. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/pyproject.toml +0 -0
  48. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/pytest.ini +0 -0
  49. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/scripts/fix-ruff.sh +0 -0
  50. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/setup.cfg +0 -0
  51. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/__init__.py +0 -0
  52. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/__version__.py +0 -0
  53. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/_utils/__init__.py +0 -0
  54. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/_utils/async_utils.py +0 -0
  55. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/_utils/auth_utils.py +0 -0
  56. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/_utils/build_utils.py +0 -0
  57. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/_utils/console_utils.py +0 -0
  58. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/agent.py +0 -0
  59. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/__init__.py +0 -0
  60. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/api.py +0 -0
  61. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/__init__.py +0 -0
  62. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/auth.py +0 -0
  63. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/docker.py +0 -0
  64. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/commands/spend_limit.py +0 -0
  65. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/cli/config.py +0 -0
  66. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/constants.py +0 -0
  67. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/exception.py +0 -0
  68. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/py.typed +0 -0
  69. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/session.py +0 -0
  70. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud/smallwebrtc/session_manager.py +0 -0
  71. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud.egg-info/dependency_links.txt +0 -0
  72. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud.egg-info/entry_points.txt +0 -0
  73. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud.egg-info/requires.txt +0 -0
  74. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/src/pipecatcloud.egg-info/top_level.txt +0 -0
  75. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/__init__.py +0 -0
  76. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_agent_args.py +0 -0
  77. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_agent_sessions.py +0 -0
  78. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_agent_stop.py +0 -0
  79. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_auth.py +0 -0
  80. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_build_id_display.py +0 -0
  81. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_build_utils.py +0 -0
  82. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_config_file.py +0 -0
  83. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_deploy_image_pull_secret.py +0 -0
  84. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_deploy_poll.py +0 -0
  85. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_deployment_status.py +0 -0
  86. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_docker_commands.py +0 -0
  87. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_exit_codes.py +0 -0
  88. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_json_output.py +0 -0
  89. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_krisp_viva.py +0 -0
  90. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_max_session_duration.py +0 -0
  91. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_organizations.py +0 -0
  92. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_output_modes.py +0 -0
  93. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_pat_auth.py +0 -0
  94. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/tests/test_spend_limit.py +0 -0
  95. {pipecatcloud-1.1.0 → pipecatcloud-1.2.0}/uv.lock +0 -0
@@ -5,6 +5,126 @@ All notable changes to **Pipecat Cloud** will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.0] - 2026-08-27
9
+
10
+ ### Added
11
+
12
+ - **GitHub integration**, at parity with the dashboard. Connect an
13
+ organization with `pipecat cloud github connect`: it opens the GitHub App
14
+ install page and waits while the install completes, so there is nothing to
15
+ copy back into the terminal. `github status` reports the connected account
16
+ and whether the App is suspended, `github disconnect` removes the
17
+ installation and every repository link under it, and `github repos` /
18
+ `github branches <owner/repo>` list what the App can see (`--query` runs the
19
+ server-side branch search for repositories with more branches than the list
20
+ returns).
21
+
22
+ - Agents can be built from a repository instead of an image. `pipecat cloud
23
+ deploy --repo owner/repo --branch main` creates a GitHub-sourced agent,
24
+ skipping the image build and push entirely, and follows the first deploy
25
+ through its build; a `[git]` section in `pcc-deploy.toml` (`repo`, `branch`,
26
+ `dockerfile_path`, `subdirectory`) does the same, with flags taking
27
+ precedence. An existing agent's repository is changed with `pipecat cloud
28
+ agent link <name> --repo owner/repo --branch main` (also `--dockerfile-path`,
29
+ `--subdirectory` and `--no-auto-deploy`), and removed with `agent unlink`.
30
+ Linking never changes what is running.
31
+
32
+ - `pipecat cloud agent deploy <name> --github` builds and deploys the linked
33
+ branch's current HEAD without waiting for a push, printing the deploy intent
34
+ and commit. `--wait` follows the deploy and reports one of four outcomes: it
35
+ succeeded, it failed, a newer push superseded it (naming the newer commit),
36
+ or it was still building when the wait ran out. Only an observed failure and
37
+ a deploy that was never visible at all exit non-zero, so a green `--wait`
38
+ means the deploy was observed and did not fail, never merely that the wait
39
+ finished. In `--output json` the outcome is reported as `waitOutcome`.
40
+
41
+ - `pipecat cloud agent list` gains a GitHub column, and `agent status` reports
42
+ the linked repository and branch, the Dockerfile path, whether pushes
43
+ auto-deploy, the commit actually running, and the latest deploy attempt with
44
+ its failure reason. A running commit that did not come from the current link
45
+ (a freshly linked agent, or one whose repository or branch was re-pointed)
46
+ says so and names where it did come from. `pipecat cloud build status` and
47
+ `build list` show the repository, ref and commit a build came from.
48
+
49
+ - Architecture selection on deploy: `pipecat cloud deploy --architecture
50
+ {amd64,arm64}` or an `architecture` key in `pcc-deploy.toml` (the flag
51
+ wins). Omitted, the region's default applies — set it when your image is
52
+ built for one architecture, so it schedules on matching nodes instead of
53
+ failing at startup. The choice is validated against the target region's
54
+ supported architectures before anything uploads, when the API advertises
55
+ them. `pipecat cloud regions list` shows each region's supported and
56
+ default architectures (Daily-hosted regions are arm64-only today), and
57
+ `pipecat cloud agent status` shows the deployment's architecture.
58
+
59
+ - Region lifecycle commands for **self-hosted regions**. `pipecat cloud
60
+ regions register <key>` registers or updates a region (an omit-preserves
61
+ upsert: options you leave out keep their stored values), with a one-question
62
+ display-name prompt so region pickers show a friendly name instead of the
63
+ uppercased key. `regions show <key>` prints the full record, including the
64
+ enrollment status and intermediate certificate expiry. `regions delete
65
+ <key>` revokes a region, surfacing the server's refusal while live sessions
66
+ or deployed services exist; that guard has no bypass, so delete the region's
67
+ agents first, and `--yes` only skips the confirmation prompt.
68
+ `regions enroll-token <key>` mints the one-time enrollment token and
69
+ prints the ready-to-paste `kubectl create secret` command.
70
+
71
+ - New `pipecat cloud organizations registry-keys` command group for the
72
+ pull-only registry credentials that fetch the region package chart:
73
+ `mint` (the key is shown exactly once, with the matching
74
+ `helm registry login` command), `list` (lifecycle metadata only, never key
75
+ material), and `revoke`. With these plus `regions register` and
76
+ `regions enroll-token`, a self-hosted region's control-plane setup needs no
77
+ raw API calls.
78
+
79
+ - Explicit agent sizing for **enterprise (self-hosted) regions**. Deploys can
80
+ now state resources directly instead of selecting an agent profile:
81
+ `pipecat cloud deploy --resources cpu=2,memory=4Gi`, or a `[resources]`
82
+ section in `pcc-deploy.toml` with `cpu` and `memory` keys. Explicit
83
+ resources and `--profile` are mutually exclusive, and the API accepts
84
+ explicit resources only for agents in self-hosted regions.
85
+
86
+ - `pipecat cloud agent status` now shows the agent's resolved resources
87
+ (cpu/memory) from the deployment. For agents deployed with explicit
88
+ resources rather than a profile, this is the sizing surface.
89
+
90
+ - New `pipecat cloud agent profiles` command group. `list` shows the platform
91
+ catalog plus any profiles your organization has defined; `create`, `update`,
92
+ `disable`, and `enable` manage organization-defined profiles for self-hosted
93
+ regions. Profile edits apply to future deploys only: running agents keep the
94
+ resources their deployment captured. Disabling is reversible: a disabled
95
+ profile can't be selected for new deploys until re-enabled.
96
+
97
+ - New `pipecat cloud secrets reference <name> --region <region>` command for
98
+ **self-hosted regions**. It registers an existing Kubernetes Secret from your
99
+ workloads namespace as a secret set, so agents can use it by set name exactly
100
+ as they use managed sets, and the Kubernetes Secret's name becomes the secret
101
+ set name. The secret stays in your cluster: Pipecat Cloud records only its
102
+ name, region, and readiness, never its values or keys, and you keep managing
103
+ its contents with your own tooling such as `kubectl`. `--region` is required
104
+ and must name a self-hosted region; cloud regions are rejected.
105
+
106
+ ### Fixed
107
+
108
+ - `pipecat cloud deploy` no longer rejects a referenced secret set as "not
109
+ found". The existence check read the set's array of key names and tested it
110
+ for truthiness, but referenced sets carry no key names by design (Daily
111
+ stores name, region and readiness, never the contents), so every healthy
112
+ referenced set failed the check and no self-hosted deploy could use one. The
113
+ check now fetches the set itself; readiness is still enforced server-side.
114
+
115
+ - `pipecat cloud secrets set` no longer treats an existing referenced secret
116
+ set as nonexistent. It had the same key-names misread as the deploy guard,
117
+ so it announced "Creating secret set" and then failed on the server's
118
+ name-uniqueness check. It now refuses before asking for confirmation,
119
+ pointing at the cluster as the place a referenced set's contents are
120
+ managed.
121
+
122
+ - `pipecat cloud agent status` plain output (`--output plain`, or any piped
123
+ invocation) now includes the Architecture and Resources rows. Both were
124
+ rendered only in the rich table, so scripted output silently omitted the
125
+ deployment's architecture and, for agents sized with explicit resources,
126
+ their only visible sizing.
127
+
8
128
  ## [1.1.0] - 2026-08-05
9
129
 
10
130
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipecatcloud
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: Cloud hosting for Pipecat AI applications
5
5
  License: BSD 2-Clause License
6
6
  Project-URL: Website, https://docs.pipecat.ai/pipecat-cloud
@@ -4,8 +4,10 @@
4
4
  # SPDX-License-Identifier: BSD 2-Clause License
5
5
  #
6
6
 
7
+ import asyncio
7
8
  import functools
8
9
  import os
10
+ import time
9
11
  from collections.abc import Callable
10
12
  from datetime import UTC, datetime
11
13
  from enum import Enum
@@ -301,6 +303,120 @@ class KrispVivaConfig:
301
303
  return {"audio_filter": self.audio_filter}
302
304
 
303
305
 
306
+ # Mirrors the server-side k8s quantity validation (positive decimal + optional
307
+ # suffix). Kept client-side so a typo fails before any network round-trip.
308
+ K8S_QUANTITY_PATTERN = r"^[0-9]+(\.[0-9]+)?(m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$"
309
+
310
+
311
+ @dataclass
312
+ class ResourcesConfig:
313
+ """Explicit sizing for agents in enterprise (self-hosted) regions.
314
+
315
+ Mutually exclusive with agent_profile: a deploy either references a named
316
+ profile or states cpu/memory directly. Only accepted by the API for
317
+ services in self-hosted regions.
318
+ """
319
+
320
+ cpu: str | None = None
321
+ memory: str | None = None
322
+
323
+ def __attrs_post_init__(self):
324
+ import re
325
+
326
+ if (self.cpu is None) != (self.memory is None):
327
+ raise ValueError("resources requires both 'cpu' and 'memory'")
328
+ for name, value in (("cpu", self.cpu), ("memory", self.memory)):
329
+ if value is not None and not re.match(K8S_QUANTITY_PATTERN, str(value)):
330
+ raise ValueError(
331
+ f"Invalid {name} quantity '{value}' (expected e.g. '500m', '2', '4Gi')"
332
+ )
333
+
334
+ def is_set(self) -> bool:
335
+ return self.cpu is not None
336
+
337
+ def to_dict(self):
338
+ return {"cpu": self.cpu, "memory": self.memory}
339
+
340
+
341
+ def parse_resources_option(value: str) -> "ResourcesConfig":
342
+ """Parse the --resources CLI value ("cpu=2,memory=4Gi") into a ResourcesConfig.
343
+
344
+ Raises ValueError with a specific message on malformed input (unknown keys,
345
+ missing cpu/memory, bad quantities) so the caller can surface exactly what
346
+ was wrong rather than a generic usage error.
347
+ """
348
+ parts: dict[str, str] = {}
349
+ for chunk in value.split(","):
350
+ key, sep, val = chunk.partition("=")
351
+ if not sep or not val.strip():
352
+ raise ValueError(
353
+ f"Malformed --resources segment '{chunk.strip()}'. "
354
+ "Expected key=value pairs, e.g. cpu=2,memory=4Gi"
355
+ )
356
+ parts[key.strip()] = val.strip()
357
+ if set(parts.keys()) != {"cpu", "memory"}:
358
+ raise ValueError(
359
+ "--resources requires exactly 'cpu' and 'memory', "
360
+ f"got: {', '.join(sorted(parts.keys())) or 'nothing'}"
361
+ )
362
+ # ResourcesConfig raises its own specific ValueError for bad quantities.
363
+ return ResourcesConfig(cpu=parts["cpu"], memory=parts["memory"])
364
+
365
+
366
+ @dataclass
367
+ class GitSourceConfig:
368
+ """A GitHub repo/branch to build the agent from, instead of an image.
369
+
370
+ Only valid when creating an agent: the API accepts `git` on create and
371
+ ignores it on update, so a binding change on an existing agent goes
372
+ through `agent link` rather than silently doing nothing here.
373
+ """
374
+
375
+ repo: str | None = None
376
+ branch: str | None = None
377
+ dockerfile_path: str | None = None
378
+ subdirectory: str | None = None
379
+
380
+ def __attrs_post_init__(self):
381
+ # Same rules the API applies, so a typo fails before the round-trip.
382
+ from pipecatcloud._utils.github_utils import (
383
+ is_valid_branch_name,
384
+ is_valid_repo_full_name,
385
+ )
386
+
387
+ if self.repo is not None and not is_valid_repo_full_name(self.repo):
388
+ raise ValueError(f"Invalid repo '{self.repo}'. Expected the form 'owner/repo'.")
389
+ if self.branch is not None and not is_valid_branch_name(self.branch):
390
+ raise ValueError(
391
+ f"Invalid branch '{self.branch}'. A branch must be a valid git ref with no "
392
+ "empty or dot-only segments."
393
+ )
394
+ # A half-specified source would otherwise reach the API as a plain
395
+ # image deploy, which is a confusing way to learn the branch is missing.
396
+ if (self.repo is None) != (self.branch is None):
397
+ raise ValueError("A GitHub source requires both 'repo' and 'branch'")
398
+
399
+ def is_set(self) -> bool:
400
+ return self.repo is not None
401
+
402
+ def to_payload(self) -> dict:
403
+ """The `git` object for the create-service request."""
404
+ payload = {"repoFullName": self.repo, "branch": self.branch}
405
+ if self.dockerfile_path:
406
+ payload["dockerfilePath"] = self.dockerfile_path
407
+ if self.subdirectory:
408
+ payload["subdirectory"] = self.subdirectory
409
+ return payload
410
+
411
+ def to_dict(self):
412
+ return {
413
+ "repo": self.repo,
414
+ "branch": self.branch,
415
+ "dockerfile_path": self.dockerfile_path,
416
+ "subdirectory": self.subdirectory,
417
+ }
418
+
419
+
304
420
  @dataclass
305
421
  class BuildConfig:
306
422
  """Configuration for cloud builds."""
@@ -329,10 +445,15 @@ class DeployConfigParams:
329
445
  docker_config: dict = field(factory=dict)
330
446
  build_config: BuildConfig = field(factory=BuildConfig) # Cloud build configuration
331
447
  agent_profile: str | None = None
448
+ resources: ResourcesConfig = field(factory=ResourcesConfig)
332
449
  krisp_viva: KrispVivaConfig = field(factory=KrispVivaConfig)
450
+ git: GitSourceConfig = field(factory=GitSourceConfig)
333
451
  force_redeploy: bool = False
334
452
  websocket_auth: str | None = None
335
453
  max_session_duration: int | None = None
454
+ # CPU architecture the agent image requires (PCC-1105). Exactly the
455
+ # kubernetes.io/arch vocabulary; omitted = the region's default.
456
+ architecture: str | None = None
336
457
 
337
458
  def __attrs_post_init__(self):
338
459
  if self.image is not None and ":" not in self.image:
@@ -340,8 +461,19 @@ class DeployConfigParams:
340
461
  # Cannot specify both image and build_id
341
462
  if self.image is not None and self.build_id is not None:
342
463
  raise ValueError("Cannot specify both 'image' and 'build_id'")
464
+ # A git-sourced agent's image is produced by the build its first deploy
465
+ # triggers, so there is nothing to supply up front. The API rejects the
466
+ # combination too; failing here just does it sooner.
467
+ if self.git.is_set() and (self.image is not None or self.build_id is not None):
468
+ raise ValueError("Cannot specify a GitHub source together with 'image' or 'build_id'")
343
469
  if self.max_session_duration is not None and not 60 <= self.max_session_duration <= 14400:
344
470
  raise ValueError("max_session_duration must be between 60 and 14400 seconds")
471
+ # Sizing is one of: a named profile, or explicit resources (enterprise
472
+ # regions). The API enforces this too; failing here is just faster.
473
+ if self.agent_profile is not None and self.resources.is_set():
474
+ raise ValueError("Cannot specify both 'agent_profile' and 'resources'")
475
+ if self.architecture is not None and self.architecture not in ("amd64", "arm64"):
476
+ raise ValueError("architecture must be 'amd64' or 'arm64'")
345
477
 
346
478
  def to_dict(self):
347
479
  return {
@@ -355,12 +487,40 @@ class DeployConfigParams:
355
487
  "docker_config": self.docker_config,
356
488
  "build_config": self.build_config.to_dict() if self.build_config else None,
357
489
  "agent_profile": self.agent_profile,
490
+ "resources": self.resources.to_dict() if self.resources.is_set() else None,
358
491
  "krisp_viva": self.krisp_viva.to_dict() if self.krisp_viva else None,
492
+ "git": self.git.to_dict() if self.git.is_set() else None,
359
493
  "websocket_auth": self.websocket_auth,
360
494
  "max_session_duration": self.max_session_duration,
495
+ "architecture": self.architecture,
361
496
  }
362
497
 
363
498
 
499
+ def validate_git_source_combination(config: DeployConfigParams) -> str | None:
500
+ """What a GitHub source cannot be combined with, or None when it is fine.
501
+
502
+ A separate check from DeployConfigParams' own validation because the deploy
503
+ command mutates the config field by field after constructing it, so
504
+ __attrs_post_init__ never sees the merged result.
505
+ """
506
+ if not config.git.is_set():
507
+ return None
508
+ if config.image or config.build_id:
509
+ return (
510
+ "Cannot deploy from a GitHub repository and an image or build at the same "
511
+ "time. Drop --image/--build-id, or drop --repo."
512
+ )
513
+ # A git agent's first-deploy config is stashed on its binding, and explicit
514
+ # resources are not plumbed through that path yet, so the API refuses the
515
+ # pair. Saying so here saves the round-trip.
516
+ if config.resources.is_set():
517
+ return (
518
+ "Explicit resources are not yet supported for GitHub-sourced agents. "
519
+ "Use [bold]--profile[/bold] instead."
520
+ )
521
+ return None
522
+
523
+
364
524
  def load_deploy_config_file() -> DeployConfigParams | None:
365
525
  from pipecatcloud.cli.config import deploy_config_path
366
526
 
@@ -385,6 +545,14 @@ def load_deploy_config_file() -> DeployConfigParams | None:
385
545
  krisp_viva_data = config_data.pop("krisp_viva", {})
386
546
  krisp_viva_config = KrispVivaConfig(**krisp_viva_data)
387
547
 
548
+ # Extract explicit resources if present (enterprise regions)
549
+ resources_data = config_data.pop("resources", {})
550
+ resources_config = ResourcesConfig(**resources_data)
551
+
552
+ # Extract GitHub source if present (PCC-933)
553
+ git_data = config_data.pop("git", {})
554
+ git_config = GitSourceConfig(**git_data)
555
+
388
556
  # Extract build configuration if present
389
557
  build_data = config_data.pop("build", {})
390
558
  exclude_data = build_data.pop("exclude", {})
@@ -408,8 +576,11 @@ def load_deploy_config_file() -> DeployConfigParams | None:
408
576
  "build",
409
577
  "agent_profile",
410
578
  "krisp_viva",
579
+ "git",
411
580
  "websocket_auth",
412
581
  "max_session_duration",
582
+ "resources",
583
+ "architecture",
413
584
  }
414
585
 
415
586
  # TODO: Remove this enable_krisp migration hint in the 2.0.0 release.
@@ -429,6 +600,8 @@ def load_deploy_config_file() -> DeployConfigParams | None:
429
600
  docker_config=docker_data,
430
601
  build_config=build_config,
431
602
  krisp_viva=krisp_viva_config,
603
+ resources=resources_config,
604
+ git=git_config,
432
605
  )
433
606
 
434
607
  return validated_config
@@ -477,3 +650,156 @@ def with_deploy_config(func: Callable) -> Callable:
477
650
  return func(*args, **kwargs)
478
651
 
479
652
  return wrapper
653
+
654
+
655
+ # How long `--wait` follows a GitHub deploy before handing it back. A GitHub
656
+ # deploy builds an image first, so it is legitimately slower than an image
657
+ # deploy; the cap exists so a wedged build can't hang a CI job forever, not to
658
+ # express an expected duration.
659
+ GIT_DEPLOY_WAIT_SECONDS = 20 * 60
660
+ GIT_DEPLOY_POLL_SECONDS = 5
661
+
662
+
663
+ class GitDeployWait(Enum):
664
+ """How a `--wait` ended. Four outcomes, because three of them are not
665
+ "succeeded" and collapsing them loses the distinction that matters."""
666
+
667
+ # A status we actually observed reach a terminal state.
668
+ TERMINAL = "terminal"
669
+ # Observed, still moving when the budget ran out. Says nothing bad about
670
+ # the deploy.
671
+ IN_FLIGHT = "in_flight"
672
+ # A newer attempt became the agent's latest, so ours is no longer
673
+ # observable (and, unless it had already reached `deploying`, was
674
+ # cancelled by the supersede).
675
+ SUPERSEDED = "superseded"
676
+ # Never saw our attempt at all: every poll failed, or no attempt was ever
677
+ # enqueued. We know nothing, which is different from knowing it is fine.
678
+ UNOBSERVED = "unobserved"
679
+
680
+
681
+ @dataclass
682
+ class GitDeployResult:
683
+ outcome: GitDeployWait
684
+ deploy: dict | None = None
685
+ superseded_by: str | None = None
686
+
687
+
688
+ async def follow_git_deploy(agent_name: str, org: str | None, commit_sha: str) -> GitDeployResult:
689
+ """Poll an agent's latest deploy attempt until it resolves.
690
+
691
+ Polls the service read endpoint rather than a deploy-intent endpoint: the
692
+ API surfaces the attempt as `latestDeploy` (PCC-978), which is also what
693
+ `agent status` reads, so both agree on what a deploy is doing.
694
+
695
+ `commit_sha` is the attempt we are entitled to report on. Empty means
696
+ "whatever attempt exists", which is only correct for an agent's first
697
+ deploy, where there is nothing that could have superseded it.
698
+ """
699
+ # Imported at call time: api.py imports this module for DeployConfigParams,
700
+ # so a module-level import of the API client would close that cycle.
701
+ from pipecatcloud._utils.console_utils import console
702
+ from pipecatcloud._utils.github_utils import is_deploy_in_flight
703
+ from pipecatcloud.cli.api import API
704
+
705
+ deadline = time.monotonic() + GIT_DEPLOY_WAIT_SECONDS
706
+ latest: dict | None = None
707
+ last_status: str | None = None
708
+
709
+ with console.status("[dim]Waiting for the deploy...[/dim]", spinner="bouncingBar") as live:
710
+ while time.monotonic() < deadline:
711
+ # A blip mid-deploy should not abort a wait that is otherwise
712
+ # healthy; keep polling and let the deadline decide.
713
+ data, error = await API.bubble_error().agent(agent_name=agent_name, org=org)
714
+ if not error and data:
715
+ candidate = data.get("latestDeploy")
716
+ if candidate:
717
+ found_sha = candidate.get("commitSha")
718
+ # A different commit is positive evidence, not an absence
719
+ # of it. The API reads this from the primary ordered by
720
+ # created_at DESC, and our own intent was already
721
+ # committed when the trigger answered 202 — so anything
722
+ # else holding "latest" is strictly newer, and ours can
723
+ # never reclaim the spot. Waiting out the budget here
724
+ # would report a stale snapshot twenty minutes later.
725
+ if commit_sha and found_sha != commit_sha:
726
+ return GitDeployResult(
727
+ GitDeployWait.SUPERSEDED,
728
+ deploy=latest,
729
+ superseded_by=found_sha,
730
+ )
731
+ latest = candidate
732
+ status_value = candidate.get("status")
733
+ if status_value != last_status:
734
+ last_status = status_value
735
+ live.update(f"[dim]Deploy {status_value}...[/dim]")
736
+ if not is_deploy_in_flight(candidate):
737
+ return GitDeployResult(GitDeployWait.TERMINAL, deploy=latest)
738
+ await asyncio.sleep(GIT_DEPLOY_POLL_SECONDS)
739
+
740
+ if latest is None:
741
+ return GitDeployResult(GitDeployWait.UNOBSERVED)
742
+ return GitDeployResult(GitDeployWait.IN_FLIGHT, deploy=latest)
743
+
744
+
745
+ def report_git_deploy_result(
746
+ result: GitDeployResult, agent_name: str, *, first_deploy: bool = False
747
+ ) -> None:
748
+ """Render a finished `--wait` and set the exit code.
749
+
750
+ Shared by both wait call sites so the four outcomes can't be interpreted
751
+ differently in two places.
752
+
753
+ On exit codes: only an observed failure and a never-observed deploy exit
754
+ non-zero. A deploy still building when the budget runs out has not failed,
755
+ and neither has one that a newer push took over. But a wait that never saw
756
+ anything cannot tell a healthy build from an API that was down the whole
757
+ time, and reporting that as success is what would make `--wait` unsafe as
758
+ a deploy gate.
759
+ """
760
+ from pipecatcloud._utils.console_utils import console
761
+ from pipecatcloud._utils.github_utils import short_sha
762
+ from pipecatcloud.cli import PIPECAT_CLI_NAME
763
+
764
+ check_hint = (
765
+ f"[dim]Check it with [bold]{PIPECAT_CLI_NAME} agent status {agent_name}[/bold].[/dim]"
766
+ )
767
+ label = "First deploy" if first_deploy else "Deploy"
768
+
769
+ if result.outcome is GitDeployWait.TERMINAL:
770
+ deploy = result.deploy or {}
771
+ status_value = deploy.get("status")
772
+ commit = deploy.get("commitSha")
773
+ if status_value == "succeeded":
774
+ console.success(
775
+ f"Deployed '{agent_name}'" + (f" from commit {short_sha(commit)}" if commit else "")
776
+ )
777
+ return
778
+ reason = deploy.get("reason") or "No reason reported."
779
+ console.error(f"{label} of '{agent_name}' {status_value}.\n{reason}")
780
+ raise typer.Exit(1)
781
+
782
+ if result.outcome is GitDeployWait.SUPERSEDED:
783
+ newer = short_sha(result.superseded_by) if result.superseded_by else "a newer commit"
784
+ console.print(
785
+ f"[yellow]Superseded: a newer deploy ({newer}) is now the latest attempt for "
786
+ f"'{agent_name}', so this one's result is no longer reported.[/yellow]\n" + check_hint
787
+ )
788
+ return
789
+
790
+ if result.outcome is GitDeployWait.IN_FLIGHT:
791
+ status_value = (result.deploy or {}).get("status") or "in progress"
792
+ console.print(
793
+ f"[yellow]Still {status_value} after waiting. The deploy continues "
794
+ f"server-side.[/yellow]\n" + check_hint
795
+ )
796
+ return
797
+
798
+ # UNOBSERVED. Never assert progress we did not see.
799
+ console.error(
800
+ f"Could not confirm the deploy of '{agent_name}': no deploy attempt was visible "
801
+ "while waiting.\n"
802
+ "[dim]The API may have been unreachable, or the attempt may never have been "
803
+ f"queued.[/dim]\n" + check_hint
804
+ )
805
+ raise typer.Exit(1)
@@ -0,0 +1,129 @@
1
+ #
2
+ # Copyright (c) 2025, Daily
3
+ #
4
+ # SPDX-License-Identifier: BSD 2-Clause License
5
+ #
6
+
7
+ """Shared helpers for the GitHub integration commands (PCC-933).
8
+
9
+ Reading an agent's binding: the API's `git` object is the *configured* intent,
10
+ while `deployedCommit` is the provenance of what is actually running. The two
11
+ can legitimately disagree — a freshly linked agent still serves its pre-link
12
+ image until the next deploy — so they are reported separately rather than
13
+ collapsed into one "GitHub" status.
14
+ """
15
+
16
+ import re
17
+
18
+ # Mirrors the API's isValidRepoFullName. Encoding alone is not enough on the
19
+ # wire: a pure-dot segment still retargets the path after normalization, so the
20
+ # shape is checked rather than escaped.
21
+ _REPO_SEGMENT = re.compile(r"^[A-Za-z0-9_.-]+$")
22
+
23
+ # Matches the git_configs.dockerfile_path column default.
24
+ DEFAULT_DOCKERFILE_PATH = "Dockerfile"
25
+
26
+ _REF_BRANCH_PREFIX = "refs/heads/"
27
+
28
+ # Deploy-intent states that mean an attempt is still in flight. Anything else
29
+ # is terminal, which is what the --wait polling keys off.
30
+ IN_FLIGHT_DEPLOY_STATUSES = ("pending", "building", "deploying")
31
+
32
+
33
+ def is_valid_repo_full_name(repo_full_name: str) -> bool:
34
+ """True for exactly two URL-safe `owner/repo` segments, no pure-dot segment."""
35
+ segments = repo_full_name.split("/")
36
+ return len(segments) == 2 and all(
37
+ _REPO_SEGMENT.match(segment) and not re.fullmatch(r"\.+", segment) for segment in segments
38
+ )
39
+
40
+
41
+ def is_valid_branch_name(branch: str) -> bool:
42
+ """True for a git ref with no empty or pure-dot segment.
43
+
44
+ Rejecting these at bind time is what stops a branch that can never be
45
+ resolved from failing on the first deploy instead.
46
+ """
47
+ if not branch:
48
+ return False
49
+ return all(segment and not re.fullmatch(r"\.+", segment) for segment in branch.split("/"))
50
+
51
+
52
+ def short_sha(sha: str) -> str:
53
+ return sha[:7]
54
+
55
+
56
+ def ref_to_branch(ref: str) -> str:
57
+ """`refs/heads/main` -> `main`. Anything else is returned unchanged."""
58
+ return ref[len(_REF_BRANCH_PREFIX) :] if ref.startswith(_REF_BRANCH_PREFIX) else ref
59
+
60
+
61
+ def is_deploy_in_flight(deploy: dict | None) -> bool:
62
+ return bool(deploy) and (deploy or {}).get("status") in IN_FLIGHT_DEPLOY_STATUSES
63
+
64
+
65
+ def installation_settings_url(installation: dict) -> str:
66
+ """The GitHub page for managing this installation's repo access.
67
+
68
+ The path differs for organization and user accounts; the API normalizes
69
+ `githubAccountType` to lowercase.
70
+ """
71
+ login = installation.get("githubAccountLogin", "")
72
+ installation_id = installation.get("githubInstallationId", "")
73
+ if str(installation.get("githubAccountType", "")).lower() == "organization":
74
+ return f"https://github.com/organizations/{login}/settings/installations/{installation_id}"
75
+ return f"https://github.com/settings/installations/{installation_id}"
76
+
77
+
78
+ def repo_url(repo_full_name: str) -> str:
79
+ return f"https://github.com/{repo_full_name}"
80
+
81
+
82
+ def commit_url(repo_full_name: str, sha: str) -> str:
83
+ return f"https://github.com/{repo_full_name}/commit/{sha}"
84
+
85
+
86
+ def binding_summary(git: dict | None) -> str:
87
+ """One-line `owner/repo@branch` for a binding, or a dash when unlinked."""
88
+ if not git:
89
+ return "—"
90
+ return f"{git.get('repoFullName', '?')}@{git.get('branch', '?')}"
91
+
92
+
93
+ def describe_deploy(deploy: dict | None) -> str:
94
+ """A deploy attempt as `status (abc1234)`, with its reason when it failed."""
95
+ if not deploy:
96
+ return "—"
97
+ status = deploy.get("status", "unknown")
98
+ sha = deploy.get("commitSha")
99
+ text = f"{status} ({short_sha(sha)})" if sha else status
100
+ reason = deploy.get("reason")
101
+ if reason and status in ("failed", "cancelled"):
102
+ text += f": {reason}"
103
+ return text
104
+
105
+
106
+ def is_running_linked_binding(git: dict | None, deployed_commit: dict | None) -> bool:
107
+ """Whether what is running actually came from the linked repo and branch.
108
+
109
+ False for the three states worth calling out: an agent just linked and
110
+ still serving its pre-link image, one whose repo was re-pointed, and one
111
+ whose branch was changed. All are legitimate, and all mean nothing from
112
+ this binding is live yet.
113
+
114
+ Repo names compare case-insensitively (GitHub treats owner/repo that way);
115
+ branches compare exactly, since git refs are case-sensitive. A missing
116
+ `ref` means "unknown", not "mismatched" — an older build recorded without
117
+ one says nothing about the branch.
118
+ """
119
+ if not git or not deployed_commit:
120
+ return False
121
+ deployed_repo = deployed_commit.get("repoFullName")
122
+ if not deployed_repo:
123
+ return False
124
+ if deployed_repo.lower() != str(git.get("repoFullName", "")).lower():
125
+ return False
126
+ ref = deployed_commit.get("ref")
127
+ if not ref:
128
+ return True
129
+ return ref_to_branch(ref) == git.get("branch")