pipecatcloud 1.4.0__tar.gz → 1.5.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 (103) hide show
  1. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/CHANGELOG.md +65 -0
  2. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/CLAUDE.md +1 -0
  3. {pipecatcloud-1.4.0/src/pipecatcloud.egg-info → pipecatcloud-1.5.0}/PKG-INFO +5 -1
  4. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/README.md +4 -0
  5. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/pyproject.toml +1 -1
  6. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/_utils/build_utils.py +11 -4
  7. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/_utils/deploy_utils.py +2 -1
  8. pipecatcloud-1.5.0/src/pipecatcloud/_utils/log_utils.py +42 -0
  9. pipecatcloud-1.5.0/src/pipecatcloud/_utils/regions.py +99 -0
  10. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/api.py +183 -4
  11. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/commands/agent.py +6 -8
  12. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/commands/auth.py +4 -3
  13. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/commands/deploy.py +7 -10
  14. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/commands/organizations.py +2 -1
  15. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/commands/secrets.py +244 -51
  16. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/entry_point.py +7 -0
  17. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/constants.py +1 -1
  18. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0/src/pipecatcloud.egg-info}/PKG-INFO +5 -1
  19. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud.egg-info/SOURCES.txt +5 -0
  20. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud.egg-info/scm_file_list.json +5 -0
  21. pipecatcloud-1.5.0/src/pipecatcloud.egg-info/scm_version.json +8 -0
  22. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_api_client.py +2 -0
  23. pipecatcloud-1.5.0/tests/test_log_redaction.py +472 -0
  24. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_regions.py +0 -57
  25. pipecatcloud-1.5.0/tests/test_secrets_bulk_write.py +450 -0
  26. pipecatcloud-1.5.0/tests/test_secrets_keys.py +244 -0
  27. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_secrets_reference.py +4 -8
  28. pipecatcloud-1.5.0/tests/test_secrets_retry.py +550 -0
  29. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/uv.lock +147 -142
  30. pipecatcloud-1.4.0/src/pipecatcloud/_utils/regions.py +0 -89
  31. pipecatcloud-1.4.0/src/pipecatcloud.egg-info/scm_version.json +0 -8
  32. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/.claude/settings.json +0 -0
  33. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/.claude/skills/update-docs/SOURCE_DOC_MAPPING.md +0 -0
  34. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/.github/workflows/format.yml +0 -0
  35. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/.github/workflows/publish-pypi.yml +0 -0
  36. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/.github/workflows/publish-test.yml +0 -0
  37. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/.github/workflows/python-compatibility.yml +0 -0
  38. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/.github/workflows/tests.yml +0 -0
  39. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/.github/workflows/update-docs.yml +0 -0
  40. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/.gitignore +0 -0
  41. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/.pre-commit-config.yaml +0 -0
  42. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/LICENSE +0 -0
  43. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/pipecat-cloud.png +0 -0
  44. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/pytest.ini +0 -0
  45. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/scripts/fix-ruff.sh +0 -0
  46. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/scripts/release-changelog.py +0 -0
  47. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/setup.cfg +0 -0
  48. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/__init__.py +0 -0
  49. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/__version__.py +0 -0
  50. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/_utils/__init__.py +0 -0
  51. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/_utils/async_utils.py +0 -0
  52. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/_utils/auth_utils.py +0 -0
  53. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/_utils/console_utils.py +0 -0
  54. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/_utils/github_utils.py +0 -0
  55. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/agent.py +0 -0
  56. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/__init__.py +0 -0
  57. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/api.py +0 -0
  58. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/commands/__init__.py +0 -0
  59. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/commands/agent_profiles.py +0 -0
  60. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/commands/build.py +0 -0
  61. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/commands/docker.py +0 -0
  62. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/commands/github.py +0 -0
  63. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/commands/regions.py +0 -0
  64. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/commands/registry_keys.py +0 -0
  65. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/commands/spend_limit.py +0 -0
  66. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/cli/config.py +0 -0
  67. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/config.py +0 -0
  68. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/exception.py +0 -0
  69. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/py.typed +0 -0
  70. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/session.py +0 -0
  71. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud/smallwebrtc/session_manager.py +0 -0
  72. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud.egg-info/dependency_links.txt +0 -0
  73. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud.egg-info/entry_points.txt +0 -0
  74. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud.egg-info/requires.txt +0 -0
  75. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/src/pipecatcloud.egg-info/top_level.txt +0 -0
  76. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/__init__.py +0 -0
  77. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/conftest.py +0 -0
  78. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_agent_args.py +0 -0
  79. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_agent_profiles_and_resources.py +0 -0
  80. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_agent_sessions.py +0 -0
  81. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_agent_status_output.py +0 -0
  82. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_agent_stop.py +0 -0
  83. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_auth.py +0 -0
  84. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_build_id_display.py +0 -0
  85. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_build_utils.py +0 -0
  86. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_config_file.py +0 -0
  87. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_deploy_architecture.py +0 -0
  88. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_deploy_image_pull_secret.py +0 -0
  89. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_deploy_poll.py +0 -0
  90. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_deploy_referenced_secret_set.py +0 -0
  91. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_deployment_status.py +0 -0
  92. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_docker_commands.py +0 -0
  93. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_exit_codes.py +0 -0
  94. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_github_integration.py +0 -0
  95. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_json_output.py +0 -0
  96. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_krisp_viva.py +0 -0
  97. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_max_session_duration.py +0 -0
  98. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_organizations.py +0 -0
  99. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_output_modes.py +0 -0
  100. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_pat_auth.py +0 -0
  101. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_regions_lifecycle.py +0 -0
  102. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_secrets_set_referenced.py +0 -0
  103. {pipecatcloud-1.4.0 → pipecatcloud-1.5.0}/tests/test_spend_limit.py +0 -0
@@ -5,6 +5,71 @@ 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.5.0] - 2026-11-05
9
+
10
+ ### Added
11
+
12
+ - `secrets keys --region <region>` shows your organization's secret keys in a
13
+ region: each key's fingerprint, state, created and retired times, and how
14
+ many secrets it holds, as text or with `--output json`. The fingerprint is
15
+ the one a security notice names and, in a self-hosted region, the one your
16
+ cluster shows for the key. It never shows key material. Against a server
17
+ that does not offer this yet, it says so and exits 1.
18
+
19
+ ### Changed
20
+
21
+ - `secrets set` with several values now writes them in one request, which the
22
+ server applies whole or not at all, so a large set takes one write in place
23
+ of one per key. Against a server that does not take this yet, including one
24
+ part way through an update, it writes one key at a time as before, and still
25
+ says how far it got if one fails.
26
+ - `secrets set`, `secrets unset` and `secrets image-pull-secret` now wait and
27
+ retry when the API answers a write with a 503 and a `Retry-After` of up to
28
+ 30 seconds, as a region can on its first secrets write. They retry up to 3 times, say they
29
+ are waiting, and fail as before if the 503 persists. Other errors, and a 503
30
+ without a usable `Retry-After`, are not retried. A `Retry-After` above 30
31
+ seconds is not waited out; the error says how long the region asked for.
32
+ A write that ends in a 503 may still have reached the region, unless the
33
+ error says nothing was stored. Running the same command again settles it.
34
+
35
+ ### Fixed
36
+
37
+ - `--region` is now checked against the regions of the organization given
38
+ with `--organization`, not only the organization in your config file. This
39
+ affects `secrets set`, `secrets list`, `secrets image-pull-secret`,
40
+ `secrets reference`, `agent list` and `deploy`. A region that belongs only
41
+ to the other organization, such as a self-hosted region, was refused as
42
+ invalid.
43
+ - `secrets set` no longer reports success when a write gets no answer from the
44
+ server, as when the connection drops. It says so and exits 1.
45
+
46
+ ### Security
47
+
48
+ - Logs no longer contain secret values or other request data. With debug
49
+ logging on (`PIPECAT_CLI_LOG_LEVEL=DEBUG` for the CLI, `PCC_LOG_LEVEL=DEBUG`
50
+ for the Python package), `secrets set` wrote the values it was given to
51
+ stderr, and every API request wrote its body and query there, including
52
+ image pull credentials and session data. Requests now log their method,
53
+ path and how much they carry. Tokens were not in these lines. If you ran
54
+ with debug logging on and the output was kept, for example in CI logs,
55
+ change the values it contains.
56
+ - An unexpected error in the CLI could print its local variables in the
57
+ traceback, including secret values and your token. Tracebacks now show code
58
+ only. To see them as before, set `TYPER_STANDARD_TRACEBACK` to an empty value.
59
+ - A failed build context upload now logs its status and S3 error code, not
60
+ S3's error document, which can repeat signed upload details. This line is
61
+ logged at the default level.
62
+ - `secrets set` no longer shows the first characters of each value when it asks
63
+ you to confirm. It shows a fixed mask, the same for any value. Without
64
+ `--skip` and without a terminal, as in CI, it printed those characters and
65
+ then failed; it now fails first. If such output was kept, change the values
66
+ it contains.
67
+ - The locked versions of `virtualenv`, `packaging`, `distlib`, `Pillow` and
68
+ `setuptools` are upgraded for security fixes. These are the versions in
69
+ `uv.lock`, used to develop and test this package. What it requires when you
70
+ install it has not changed, so upgrade your own environment if it holds an
71
+ older one.
72
+
8
73
  ## [1.4.0] - 2026-10-22
9
74
 
10
75
  ### Added
@@ -103,6 +103,7 @@ pcc-deploy.toml Example deployment config consumed by `pipecat cloud de
103
103
  - Auth uses OAuth2 with PKCE (RFC 7636). OAuth endpoints are discovered from the API server, not hardcoded. The callback server tries ports 8400-8404.
104
104
  - The session argument types in `agent.py` subclass pipecat-ai's runner types and need pipecat-ai (>= 1.0.0); there are no fallback definitions. `MOQSessionArguments` needs 1.12.0 (`MOQRunnerArguments.relay_url`), so it is imported and defined under its own guard: on older pipecat-ai the module still imports and only that name is missing. It stays out of `__all__` for the same reason.
105
105
  - Krisp VIVA audio filter names are `Literal["tel", "pro"]` in `constants.py`. These must match the server-side ConfigMap, so coordinate changes with the backend.
106
+ - Never log a request or response body, a header, or an exception's text: they can hold secret values and tokens. Use `_utils/log_utils.py`. `tests/test_log_redaction.py` fails on a log call that reads a name or attribute it knows can hold one, however it is formatted, or that uses `logger.opt`, `logger.exception` or `logger.catch`. Only `describe_*` and `len` calls are exempt.
106
107
 
107
108
  ## Release flow
108
109
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipecatcloud
3
- Version: 1.4.0
3
+ Version: 1.5.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
@@ -70,6 +70,10 @@ To use Pipecat Cloud programmatically (without the CLI), install the package dir
70
70
 
71
71
  4. Build and deploy your agent `pipecat cloud deploy`
72
72
 
73
+ ### Seeing your secret keys
74
+
75
+ `pipecat cloud secrets keys --region <region>` shows your organization's secret keys in a region: each key's fingerprint, state, created and retired times, and how many secrets it holds. The fingerprint is the one a security notice names and, in a self-hosted region, the one your cluster shows for the key, so you can compare them. Add `--output json` before `secrets` for a single JSON document. Key material is never shown.
76
+
73
77
  ### Usage in Python scripts
74
78
 
75
79
  If want to programmatically start an agent within a Python script, you can use the `pipecatcloud.session` module.
@@ -47,6 +47,10 @@ To use Pipecat Cloud programmatically (without the CLI), install the package dir
47
47
 
48
48
  4. Build and deploy your agent `pipecat cloud deploy`
49
49
 
50
+ ### Seeing your secret keys
51
+
52
+ `pipecat cloud secrets keys --region <region>` shows your organization's secret keys in a region: each key's fingerprint, state, created and retired times, and how many secrets it holds. The fingerprint is the one a security notice names and, in a self-hosted region, the one your cluster shows for the key, so you can compare them. Add `--output json` before `secrets` for a single JSON document. Key material is never shown.
53
+
50
54
  ### Usage in Python scripts
51
55
 
52
56
  If want to programmatically start an agent within a Python script, you can use the `pipecatcloud.session` module.
@@ -41,7 +41,7 @@ dev = [
41
41
  "pytest>=9.0.0,<10",
42
42
  "pytest-asyncio>=1.0.0,<2",
43
43
  "ruff>=0.12.11,<1",
44
- "setuptools>=83.0.0,<84",
44
+ "setuptools>=84.1.0,<85",
45
45
  "setuptools_scm~=8.3.1",
46
46
  # Imported by the tests to compare pipecat-ai versions.
47
47
  "packaging>=25.0",
@@ -17,6 +17,7 @@ import gzip
17
17
  import hashlib
18
18
  import io
19
19
  import os
20
+ import re
20
21
  import tarfile
21
22
  import time
22
23
  from collections.abc import Callable
@@ -26,6 +27,8 @@ from pathlib import Path
26
27
  import aiohttp
27
28
  from loguru import logger
28
29
 
30
+ from pipecatcloud._utils.log_utils import describe_exception
31
+
29
32
  # Default patterns to exclude from build context
30
33
  DEFAULT_EXCLUSIONS: set[str] = {
31
34
  # Version control
@@ -189,7 +192,7 @@ def load_dockerignore(context_dir: Path) -> set[str] | None:
189
192
  if normalized:
190
193
  patterns.add(normalized)
191
194
  except Exception as e:
192
- logger.warning(f"Failed to read .dockerignore: {e}")
195
+ logger.warning(f"Failed to read .dockerignore: {describe_exception(e)}")
193
196
  return None
194
197
 
195
198
  return patterns
@@ -375,11 +378,15 @@ async def upload_to_s3(
375
378
  logger.debug(f"Upload successful: {response.status}")
376
379
  return True
377
380
  else:
378
- body = await response.text()
379
- logger.error(f"Upload failed: {response.status} - {body}")
381
+ # S3's error document can echo the signed policy and the
382
+ # credential it was given, so only its code is logged.
383
+ code = re.search(r"<Code>(\w+)</Code>", await response.text())
384
+ logger.error(
385
+ f"Upload failed: {response.status}{f' ({code[1]})' if code else ''}"
386
+ )
380
387
  return False
381
388
  except Exception as e:
382
- logger.error(f"Upload error: {e}")
389
+ logger.error(f"Upload error: {describe_exception(e)}")
383
390
  return False
384
391
 
385
392
 
@@ -17,6 +17,7 @@ import typer
17
17
  from attr import dataclass, field
18
18
  from loguru import logger
19
19
 
20
+ from pipecatcloud._utils.log_utils import describe_exception
20
21
  from pipecatcloud.cli import PIPECAT_DEPLOY_CONFIG_PATH
21
22
  from pipecatcloud.constants import KRISP_VIVA_MODELS, KrispVivaAudioFilter
22
23
  from pipecatcloud.exception import ConfigFileError
@@ -607,7 +608,7 @@ def load_deploy_config_file() -> DeployConfigParams | None:
607
608
  return validated_config
608
609
 
609
610
  except Exception as e:
610
- logger.debug(e)
611
+ logger.debug(describe_exception(e))
611
612
  raise ConfigFileError(str(e))
612
613
 
613
614
 
@@ -0,0 +1,42 @@
1
+ #
2
+ # Copyright (c) 2025, Daily
3
+ #
4
+ # SPDX-License-Identifier: BSD 2-Clause License
5
+ #
6
+
7
+ """
8
+ What a log line may say about a request, a response or a failure.
9
+
10
+ Request and response bodies can hold secret values and credentials, under any name
11
+ and at any depth, and an exception's text can quote them. So a log line says how
12
+ much is there and what kind of thing it is, and never quotes any of it. Headers are
13
+ not logged at all: they carry the token.
14
+ """
15
+
16
+
17
+ def describe_url(url: str) -> str:
18
+ """A URL without its query string, whose values are not for a log."""
19
+ return url.split("?", 1)[0]
20
+
21
+
22
+ def describe_body(body: object) -> str:
23
+ """How much a request or response body holds, and never what."""
24
+ if body is None:
25
+ return "none"
26
+ if isinstance(body, dict | list | tuple):
27
+ return f"{type(body).__name__} of {len(body)}"
28
+ return type(body).__name__
29
+
30
+
31
+ def describe_exception(exc: BaseException) -> str:
32
+ """An exception's type, and its HTTP status if it has one, without its text."""
33
+ status = getattr(exc, "status", None)
34
+ return (
35
+ f"{type(exc).__name__} (status {status})" if isinstance(status, int) else type(exc).__name__
36
+ )
37
+
38
+
39
+ def describe_api_error(error: object) -> str:
40
+ """The code of an error body from the API, without its text."""
41
+ code = error.get("code") if isinstance(error, dict) else None
42
+ return f"code {code}" if code else "no code"
@@ -0,0 +1,99 @@
1
+ #
2
+ # Copyright (c) 2025, Daily
3
+ #
4
+ # SPDX-License-Identifier: BSD 2-Clause License
5
+ #
6
+
7
+ """
8
+ Region utilities for Pipecat Cloud CLI.
9
+
10
+ Provides functions to fetch and cache available regions from the API.
11
+ """
12
+
13
+ from pipecatcloud.cli.api import API
14
+ from pipecatcloud.cli.config import config
15
+
16
+ # Module-level cache of region lists, keyed by organization. Each organization
17
+ # has its own list (a self-hosted region is visible only to its organization),
18
+ # so one list must never answer for another.
19
+ _regions_cache: dict[str, list[dict[str, str]]] | None = None
20
+
21
+
22
+ async def get_regions(org: str | None = None) -> list[dict[str, str]]:
23
+ """
24
+ Fetch available regions for an organization from the API with caching.
25
+
26
+ The list is cached per organization, so the API is called once per
27
+ organization per CLI session.
28
+
29
+ Args:
30
+ org: Organization to list regions for. Defaults to the organization in
31
+ the config file.
32
+
33
+ Returns:
34
+ List of region objects with 'code' and 'display_name' fields, or an
35
+ empty list when the list cannot be fetched.
36
+ Example: [
37
+ {"code": "us-west", "display_name": "US West"},
38
+ {"code": "eu-central", "display_name": "EU Central"}
39
+ ]
40
+ """
41
+ global _regions_cache
42
+
43
+ org = org or config.get("org")
44
+ if _regions_cache is None:
45
+ _regions_cache = {}
46
+
47
+ if org not in _regions_cache:
48
+ # API returns (data, error) tuple
49
+ data, error = await API.regions(org=org)
50
+ if error or not data:
51
+ return []
52
+ _regions_cache[org] = data
53
+
54
+ return _regions_cache[org]
55
+
56
+
57
+ async def region_error(region: str, org: str | None = None) -> str | None:
58
+ """
59
+ Check a region code against the regions of the organization in use.
60
+
61
+ Fetches the list once. Returns None when the region is valid, otherwise a
62
+ message for the user. When the list cannot be fetched, the message says so
63
+ rather than calling the region invalid. The API client prints its own error
64
+ for the failed call before this message is returned (in json mode that error
65
+ goes to stdout as the error object), so the caller shows both: why the call
66
+ failed, then that the command stopped.
67
+ """
68
+ org = org or config.get("org")
69
+ regions = await get_regions(org)
70
+ if not regions:
71
+ return (
72
+ f"Could not list the regions of organization '{org}', so region "
73
+ f"'{region}' could not be checked. Not continuing."
74
+ )
75
+ codes = [r["code"] for r in regions]
76
+ if region in codes:
77
+ return None
78
+ return f"Invalid region '{region}'. Valid regions are: {', '.join(codes)}"
79
+
80
+
81
+ def validate_architecture_for_region(
82
+ architecture: str, region: str, regions: list[dict]
83
+ ) -> str | None:
84
+ """Pre-validate an architecture choice against a region's capability
85
+ (PCC-1105). Returns an error message, or None when the choice is valid
86
+ or cannot be checked locally.
87
+
88
+ The regions payload carries ``supported_architectures`` only on newer
89
+ APIs — when absent, defer to the server's own validation (its 400 names
90
+ the supported list).
91
+ """
92
+ entry = next((r for r in regions if r.get("code") == region), None)
93
+ supported = (entry or {}).get("supported_architectures")
94
+ if not supported or architecture in supported:
95
+ return None
96
+ return (
97
+ f"Architecture '{architecture}' is not supported in region '{region}' "
98
+ f"(supported: {', '.join(supported)})"
99
+ )
@@ -4,6 +4,7 @@
4
4
  # SPDX-License-Identifier: BSD 2-Clause License
5
5
  #
6
6
 
7
+ import asyncio
7
8
  import json
8
9
  import time
9
10
  from collections.abc import Callable
@@ -15,6 +16,7 @@ from loguru import logger
15
16
 
16
17
  from pipecatcloud.__version__ import version
17
18
  from pipecatcloud._utils.deploy_utils import DeployConfigParams
19
+ from pipecatcloud._utils.log_utils import describe_body, describe_exception, describe_url
18
20
  from pipecatcloud.config import config
19
21
  from pipecatcloud.exception import AgentStartError
20
22
 
@@ -33,6 +35,41 @@ def api_method(func):
33
35
  return wrapper
34
36
 
35
37
 
38
+ # A secrets write can be refused with a 503 and `Retry-After` while the region
39
+ # is not ready to store secrets yet (a self-hosted region's first write), while
40
+ # the server is busy with other writes, or when the region's answer was lost and
41
+ # the write may have landed. Repeating the same write is right for all of them.
42
+ # The CLI waits and repeats the request, bounded so a region that stays unready
43
+ # fails rather than hanging. Three retries cover a short warm-up (the server
44
+ # asks for about 5 seconds) with room to spare. The waits total at most
45
+ # 3 x 30 seconds; each of the 4 attempts can also take up to the client's own
46
+ # request timeout, so the worst case is those waits plus 4 timeouts. A larger
47
+ # `Retry-After` than the cap means the server wants longer than we are willing
48
+ # to wait. The request is not retried, and not clamped either: repeating it
49
+ # before the time the server named goes against the header. The error says so.
50
+ SECRETS_WRITE_MAX_RETRIES = 3
51
+ SECRETS_WRITE_MAX_RETRY_AFTER = 30
52
+
53
+ # How the API says what it takes. A server that writes several fields of a secret
54
+ # set in one request names this capability on its secrets responses, so the CLI
55
+ # knows without trying. One that does not is written one field at a time.
56
+ CAPABILITIES_HEADER = "Pipecat-Cloud-Capabilities"
57
+ SECRETS_BULK_WRITE_CAPABILITY = "secrets-bulk-write"
58
+
59
+
60
+ def _parse_retry_after(value: str | None) -> int | None:
61
+ """Seconds from a `Retry-After` header, or None when absent, negative, or not
62
+ a whole number of seconds (the HTTP-date form is not honoured). A value above
63
+ the cap is returned as sent; the caller decides not to wait it out."""
64
+ if value is None:
65
+ return None
66
+ try:
67
+ seconds = int(value.strip())
68
+ except ValueError:
69
+ return None
70
+ return seconds if seconds >= 0 else None
71
+
72
+
36
73
  class _API:
37
74
  def __init__(self, token: str | None = None, is_cli: bool = False):
38
75
  self.token = token
@@ -43,6 +80,15 @@ class _API:
43
80
  # carries a code of its own), so a caller that decides on the status
44
81
  # reads it here. None when the request got no response at all.
45
82
  self.error_status: int | None = None
83
+ # The `Retry-After` header of the last failed response, as sent, or
84
+ # None when it had none.
85
+ self.error_retry_after: str | None = None
86
+ # The capabilities header of the last response, as sent, or None when it
87
+ # had none.
88
+ self.capabilities_header: str | None = None
89
+ # Whether the server said, on the last `secrets_get`, that it takes
90
+ # several fields of a set in one write. False until it does.
91
+ self.secrets_bulk_write: bool = False
46
92
  self.bubble_next = False
47
93
  self.is_cli = is_cli
48
94
 
@@ -140,7 +186,12 @@ class _API:
140
186
  logger.debug("Token refresh failed, proceeding with expired token")
141
187
 
142
188
  async with aiohttp.ClientSession() as session:
143
- logger.debug(f"Request: {method} {url} {params} {json}")
189
+ # Every route comes through here, and a body or a query can hold secret
190
+ # values, so neither is logged, whichever route this is.
191
+ logger.debug(
192
+ f"Request: {method} {describe_url(url)} "
193
+ f"params: {describe_body(params)} body: {describe_body(json)}"
194
+ )
144
195
 
145
196
  response = await session.request(
146
197
  method=method,
@@ -149,6 +200,7 @@ class _API:
149
200
  params=params,
150
201
  json=json,
151
202
  )
203
+ self.capabilities_header = response.headers.get(CAPABILITIES_HEADER)
152
204
 
153
205
  if not response.ok:
154
206
  logger.debug(f"Response not ok: {response.status} {response.reason}")
@@ -156,6 +208,7 @@ class _API:
156
208
  return None
157
209
 
158
210
  self.error_status = response.status
211
+ self.error_retry_after = response.headers.get("Retry-After")
159
212
  # Extract PCC error code, where applicable
160
213
  try:
161
214
  # Try to parse the error as JSON
@@ -188,6 +241,7 @@ class _API:
188
241
  async def wrapper(*args, live=None, **kwargs):
189
242
  self.error = None
190
243
  self.error_status = None
244
+ self.error_retry_after = None
191
245
  try:
192
246
  result = await method_func(*args, **kwargs)
193
247
  self.bubble_next = False
@@ -203,7 +257,7 @@ class _API:
203
257
  raise e
204
258
 
205
259
  if self.error and not self.bubble_next:
206
- logger.debug(e)
260
+ logger.debug(describe_exception(e))
207
261
  self.print_error()
208
262
 
209
263
  self.bubble_next = False
@@ -383,11 +437,24 @@ class _API:
383
437
  Returns the full response object: {secrets, region, status, errorMessage?}.
384
438
  Use this when you need readiness; use _secrets_list when you only need
385
439
  the existence check or the flat keys array.
440
+
441
+ Also records whether the server takes several fields in one write, which
442
+ it says whether or not the set exists.
386
443
  """
387
444
  url = f"{self.construct_api_url('secrets_path').format(org=org)}/{secret_set}"
445
+ self.capabilities_header = None
446
+ self.secrets_bulk_write = False
388
447
  result = await self._base_request("GET", url, not_found_is_empty=True)
448
+ self.secrets_bulk_write = self.last_response_offers_bulk_write()
389
449
  return result or None
390
450
 
451
+ def last_response_offers_bulk_write(self) -> bool:
452
+ """Whether the last response came from a server that takes several fields in
453
+ one write. In a rolling update of the API, responses can differ on this."""
454
+ return SECRETS_BULK_WRITE_CAPABILITY in [
455
+ c.strip() for c in (self.capabilities_header or "").split(",")
456
+ ]
457
+
391
458
  @property
392
459
  def secrets_get(self):
393
460
  """Get a single secret set with readiness status.
@@ -408,7 +475,70 @@ class _API:
408
475
  if region:
409
476
  data = {**data, "region": region}
410
477
 
411
- return await self._base_request("PUT", url, json=data) or {}
478
+ # A PUT replaces the value of each key it names, so repeating it is safe.
479
+ result, _ = await self._secrets_write_with_retry("PUT", url, json=data)
480
+ return result or {}
481
+
482
+ async def _secrets_write_with_retry(
483
+ self, method: str, url: str, **kwargs
484
+ ) -> tuple[dict | None, int]:
485
+ """A secrets write, and how many times it was repeated. A 503 with
486
+ `Retry-After` is the server saying the write could not be done yet; wait and
487
+ repeat, a bounded number of times. Only for a write that is safe to repeat."""
488
+ attempt = 0
489
+ while True:
490
+ try:
491
+ return await self._base_request(method, url, **kwargs), attempt
492
+ except aiohttp.ClientResponseError as e:
493
+ wait = _parse_retry_after(self.error_retry_after)
494
+ too_long = wait is not None and wait > SECRETS_WRITE_MAX_RETRY_AFTER
495
+ if (
496
+ e.status != 503
497
+ or wait is None
498
+ or too_long
499
+ or attempt >= SECRETS_WRITE_MAX_RETRIES
500
+ ):
501
+ if e.status == 503 and isinstance(self.error, dict):
502
+ notes = []
503
+ if attempt:
504
+ # Say the retries happened, so the user does not
505
+ # think the first refusal was final.
506
+ noun = "retry" if attempt == 1 else "retries"
507
+ notes.append(f"Still unavailable after {attempt} {noun}.")
508
+ if too_long:
509
+ # Waiting less than the server named would go
510
+ # against the header, so say why nothing more was retried.
511
+ notes.append(
512
+ f"The region asked to wait {wait} seconds, longer than the "
513
+ f"CLI will wait ({SECRETS_WRITE_MAX_RETRY_AFTER}). "
514
+ "Run the command again after that."
515
+ )
516
+ if notes:
517
+ # Same fallback the console uses to show an error.
518
+ text = (
519
+ self.error.get("error")
520
+ or self.error.get("message")
521
+ or "Service unavailable."
522
+ )
523
+ self.error = {**self.error, "error": f"{text} {' '.join(notes)}"}
524
+ raise
525
+ attempt += 1
526
+ self._notify_retry(wait, attempt)
527
+ await asyncio.sleep(wait)
528
+ self.error = None
529
+ self.error_status = None
530
+ self.error_retry_after = None
531
+
532
+ def _notify_retry(self, wait: int, attempt: int) -> None:
533
+ """Tell the CLI user the request is waiting to be repeated, so a pause is not silent."""
534
+ if not self.is_cli:
535
+ return
536
+ from pipecatcloud._utils.console_utils import console
537
+
538
+ console.print(
539
+ f"[yellow]The write could not be completed yet. Retrying in {wait}s "
540
+ f"(attempt {attempt} of {SECRETS_WRITE_MAX_RETRIES})...[/yellow]"
541
+ )
412
542
 
413
543
  @property
414
544
  def secrets_upsert(self):
@@ -421,6 +551,47 @@ class _API:
421
551
  """
422
552
  return self.create_api_method(self._secrets_upsert)
423
553
 
554
+ async def _secrets_upsert_fields(
555
+ self, fields: dict[str, str], set_name: str, org: str, region: str | None = None
556
+ ) -> dict:
557
+ return await self._secrets_upsert(
558
+ data={
559
+ "secrets": [{"secretKey": key, "secretValue": val} for key, val in fields.items()]
560
+ },
561
+ set_name=set_name,
562
+ org=org,
563
+ region=region,
564
+ )
565
+
566
+ @property
567
+ def secrets_upsert_fields(self):
568
+ """Create / modify several fields of a secret set in one write, which the
569
+ server applies whole or not at all. Only for a server that says it takes
570
+ this (`secrets_bulk_write`, after `secrets_get`).
571
+
572
+ Args:
573
+ fields: the keys and values to write
574
+ set_name: name of set to create or update
575
+ org: Organization ID
576
+ region: region for secret set
577
+ """
578
+ return self.create_api_method(self._secrets_upsert_fields)
579
+
580
+ async def _secrets_keys(self, org: str, region: str) -> dict | None:
581
+ url = f"{self.construct_api_url('regions_path').format(org=org)}/{region}/secret-keys"
582
+ return await self._base_request("GET", url, not_found_is_empty=True)
583
+
584
+ @property
585
+ def secrets_keys(self):
586
+ """The organization's secret keys in a region: each key's fingerprint, state
587
+ and times. None when the server does not offer this.
588
+
589
+ Args:
590
+ org: Organization ID
591
+ region: region to read
592
+ """
593
+ return self.create_api_method(self._secrets_keys)
594
+
424
595
  async def _secrets_reference(self, name: str, region: str, org: str) -> dict:
425
596
  url = f"{self.construct_api_url('secrets_path').format(org=org)}/references"
426
597
  return await self._base_request("POST", url, json={"name": name, "region": region}) or {}
@@ -442,7 +613,15 @@ class _API:
442
613
 
443
614
  async def _secrets_delete(self, set_name: str, secret_name: str, org: str) -> dict | None:
444
615
  url = f"{self.construct_api_url('secrets_path').format(org=org)}/{set_name}/{secret_name}"
445
- return await self._base_request("DELETE", url, not_found_is_empty=True)
616
+ # Deleting a key twice leaves the same set, so it is repeated like a write.
617
+ result, retried = await self._secrets_write_with_retry(
618
+ "DELETE", url, not_found_is_empty=True
619
+ )
620
+ if result is None and retried:
621
+ # Not found once repeated: the attempt the server could not confirm
622
+ # removed it, or it was never there. Either way the key is gone.
623
+ return {"secretKey": secret_name}
624
+ return result
446
625
 
447
626
  @property
448
627
  def secrets_delete(self):
@@ -47,7 +47,8 @@ from pipecatcloud._utils.github_utils import (
47
47
  ref_to_branch,
48
48
  short_sha,
49
49
  )
50
- from pipecatcloud._utils.regions import get_region_codes, validate_region
50
+ from pipecatcloud._utils.log_utils import describe_body, describe_exception
51
+ from pipecatcloud._utils.regions import region_error
51
52
  from pipecatcloud.cli import PIPECAT_CLI_NAME
52
53
  from pipecatcloud.cli.api import API
53
54
  from pipecatcloud.cli.config import config
@@ -176,11 +177,8 @@ async def list_agents(
176
177
  org = organization or config.get("org")
177
178
 
178
179
  # Validate region if provided
179
- if region and not await validate_region(region):
180
- valid_regions = await get_region_codes()
181
- console.print(
182
- f"[red]Invalid region '{region}'. Valid regions are: {', '.join(valid_regions)}[/red]"
183
- )
180
+ if region and (region_message := await region_error(region, org=org)):
181
+ console.error(region_message)
184
182
  raise typer.Exit(1)
185
183
 
186
184
  with console.status(
@@ -269,7 +267,7 @@ async def status(
269
267
  ) as live:
270
268
  data, error = await API.agent(agent_name=agent_name, org=org, live=live)
271
269
 
272
- logger.debug(f"Agent status: {data}")
270
+ logger.debug(f"Agent status: {describe_body(data)}")
273
271
 
274
272
  live.stop()
275
273
 
@@ -1117,7 +1115,7 @@ async def deployments(
1117
1115
  )
1118
1116
  )
1119
1117
  except Exception as e:
1120
- logger.debug(e)
1118
+ logger.debug(describe_exception(e))
1121
1119
  console.api_error(error_code, f"Unable to get deployments for {agent_name}")
1122
1120
  raise typer.Exit(1)
1123
1121