Skip to content

0.5.0 (3/5): one credentials resolver and transport, release workflow, update check - #162

Closed
dg-coreylweathers wants to merge 8 commits into
release/0.5.0-2-streamingfrom
release/0.5.0-3-transport
Closed

dg-coreylweathers wants to merge 8 commits into
release/0.5.0-2-streamingfrom
release/0.5.0-3-transport

Conversation

@dg-coreylweathers

Copy link
Copy Markdown
Contributor

Part 3 of 5 of the 0.5.0 release, stacked on part 2. Review in order. Merge only after the PR below it has merged, and only after 0.4.1 is released.

Credentials are resolved in one place and every API request goes through one transport, so the host checks from 0.4.1 apply everywhere. Releases publish from release.yml with trusted publishing. The update check never waits, detects pipx, uv and npm installs correctly, and reports an unreachable index as an error.

Commits

  • c11d811 feat(core): resolve credentials in one place
  • 64399e6 fix(core): send API requests through one transport
  • 638a345 fix(debug): take the probe's and network's host from the transport
  • 0306f04 ci: keep the CLI smoke runs from sending telemetry
  • 9297ddc ci: publish every release from release.yml with trusted publishing
  • 785ff55 fix(update): never wait for the update check
  • d30a717 fix(update): detect pipx, uv and npm installs correctly
  • cbe06c7 fix(update): report an unreachable index as an error

Notes

  • Tests for the full 0.5.0 head (Docker, Linux): Python 3.10 5,616 passed, 91 skipped, 0 failed; Python 3.14 5,617 passed, 90 skipped, 0 failed; lint, types, lock, floors, READMEs and build pass. CI on this PR runs the full matrix.
  • After 0.4.1's release PR merges, this stack is rebased onto main and the lockstep versions are re-baselined to the versions that release set.
  • Merge with a merge commit, not a squash: release-please reads the individual commits.

🤖 Generated with Claude Code

deepctl_core.credentials.resolve() decides the API key and project ID
for a run and says where each came from (key_source, profile_source,
untrusted_source). The AuthManager methods (get_api_key, get_project_id,
get_credential_source, is_authenticated, has_profile_credentials) answer
from it, once per run, and dg whoami takes its key source from it.

Precedence is 0.4.1's, unchanged. Because Config merges DEEPGRAM_API_KEY
into the active default profile, that is --api-key > keyring > env >
file for the default profile, and --api-key > keyring > file > env for
another profile.

User-visible changes:
- With no API key, commands return the coded auth_required error: a
  coded result on stdout under a machine format and two stderr lines
  (ERROR and HINT). The error text "DEEPGRAM_API_KEY is not set" is
  unchanged; the old two-line "not set in the configuration file ... Run
  deepctl login" message is replaced by the catalogue hint. When the
  only key was in a user config file an AI agent session doesn't trust,
  the error is E-KEY-UNTRUSTED with next_step null.
- A DEEPGRAM_API_KEY, DEEPGRAM_PROFILE, DEEPGRAM_PROJECT_ID or
  DEEPGRAM_BASE_URL that still holds an unexpanded ${...} placeholder is
  ignored, with one W-ENV-PLACEHOLDER warning naming the variable.
- Off a terminal (including MCP), a keyring read gets 2 s; when the
  keyring doesn't answer, dg uses the config file or the environment
  instead and prints one W-KEYRING-TIMEOUT warning. This covers dg
  whoami and dg profiles too. With --api-key the keyring isn't read or
  imported.
- "Using credentials from environment variables" now also appears when
  DEEPGRAM_API_KEY is the key in use; "No project ID specified" appears
  only for commands that need a project.
- Commands that use the key remove an expired credential's keyring
  entry, as in 0.4.1, but only when the expiry comes from a trusted user
  file. whoami, login's checks, is_authenticated() and
  has_profile_credentials() stay read-only, as in 0.4.1.
- auth_invalid's details.key_source comes from the same resolution, so
  the error path reads the keyring no second time.
- AuthManager builds its HTTP client on first use, and group commands
  get a lazy client, so `dg debug <sub> --help` loads no httpx or
  keyring.

The env-vs-stored mismatch check is implemented behind a constant that
stays off until login writes fingerprints.
deepctl_core.transport.Transport owns where API requests go and what
they carry. The SDK facade builds its client through it. REST and
WebSocket base URLs come only from check_credential_host (http -> ws,
https -> wss), and Authorization is attached only after that check.

User-visible changes:
- One redirect refusal for REST and WebSocket: a 3xx is
  transport.RedirectRefused, the coded network_unavailable error
  (reason "unexpected redirect") with 0.4.1's text, which names both
  origins and says nothing was sent there (or no audio/text was sent,
  for a socket). for_status() maps every 3xx to it, so the client's own
  redirect check (_failed) and 0.4.1's separate ClickException go;
  deepctl_core.websocket re-exports the error and stays the only
  WebSocket open. Its message and exit_code read as before. Redirects
  were already refused; neither the key nor an upload body reaches
  another origin.
- A connection reset or closed after the request started (a read or
  write error, or the server disconnecting without a response) is
  network_unavailable with E-NETWORK-DROPPED ("dropped before the
  response was complete"). A reset while connecting, DNS and TLS
  failures keep E-NETWORK.
- DEEPGRAM_CLI_INSECURE now applies to the SDK's REST requests too, with
  0.4.1's rule: only 1 or true count, only localhost, 127.0.0.1 and
  [::1] skip certificate verification, every other host is verified
  (with one W-INSECURE-REMOTE warning), and AI agent sessions ignore it.
  The login client uses the same rule. WebSocket streaming, dg api and
  debug probe always verify.
- A WebSocket upgrade refused with an HTTP status maps through the same
  table as REST; DNS, connect and TLS failures map to
  network_unavailable, and an open that times out maps to timeout.
- The shared client keeps no cookies.

A backend seam (RestBackend by default) is where #142's SageMaker
backend plugs in. shared_client, map_exception and raise_for stay
module-level with unchanged signatures. dg whoami takes its default host
from hosts.DEFAULT_BASE_URL.

dg api takes its base URL from the Transport's host check and sends its
request on the process's client, so a dg api call opens one connection
like every other command. It still verifies TLS for every host, as in
0.4.1: when a person has DEEPGRAM_CLI_INSECURE set, dg api uses a
separate always-verified client, and a TLS failure on a loopback host
then says "DEEPGRAM_CLI_INSECURE doesn't apply to dg api, which always
verifies TLS certificates." It asks for no redirects, and keeps 0.4.1's
host scoping for the key.
debug probe's upstream is the Transport's checked WebSocket base instead
of a hard-coded api.deepgram.com, so it follows the profile's base URL
and a refused host never gets the key. The Authorization header comes
from the Transport after its host check.

User-visible changes:
- An upstream that rejects the key at the upgrade (401) stops the proxy
  with the coded error, exit 1, because every connection would fail the
  same way. Other open streams are closed first, so it exits within
  seconds.
- Any other refused upgrade (including 403, which is about one request's
  scope), an unreachable upstream, or an open that takes over 10 s fails
  only that client's connection, with the coded error on stderr; the
  proxy keeps serving.
- The proxy's status lines go to stderr, so stdout holds only the
  result.

debug network's default endpoint, its latency row and its proxy advice
name the host from hosts.DEFAULT_BASE_URL, through the Transport. It
still sends no credentials. debug toolkit's examples build their
streaming URL from the same constant.

The one api.deepgram.com left in a .py source outside hosts.py is the
foreign-host hint in messages.py, which a person copies; it's marked
'host-literal: user text'.
release.yml installs the built wheels and the published release and
runs deepctl in both. test.yml runs deepctl --version outside pytest,
where conftest.py's opt-out doesn't reach. Turn telemetry off for all
of them, and point test.yml's run at a dead loopback endpoint.
publish-on-release.yml rebuilt and published a tag when someone
published a GitHub release by hand. Replace it with a tag input on
release.yml's workflow_dispatch, so every PyPI upload comes from one
workflow file.

A dispatch with a tag skips release-please, checks that the tag is a
vX.Y.Z published (not draft) GitHub release in the history of main,
release/* or next/*, builds that tag, and refuses to go on unless the
build holds the deepctl wheel for the tag's version. A release that
release-please cuts builds the tag it just created, not the branch the
run started on, and goes through the same version check.

The build and install-test jobs now read the repository only and keep
no credentials in the checkout.

The publish job no longer reads a long-lived API token. It runs in the
pypi environment with id-token: write, and each PyPI project accepts it
as a trusted publisher bound to release.yml and that environment.
Each upload sends every sub-package first and the root package last,
in a second step that runs only if the first succeeded. The root pins
every sub-package ==, so it is never on the index without them.

A tag dispatch can also pick index: testpypi. That uploads the same
build to TestPyPI from a separate testpypi environment, so the setup
can be tried end to end before the first real OIDC release, and it
advertises nothing.
The update, plugin-update and skills checks no longer join their
threads at exit. A notice prints only if its check already finished;
otherwise it is dropped and the daemon thread ends with the process.

The checks now skip CI, agent and other non-interactive runs, dg mcp,
a stderr that isn't a terminal, and DEEPCTL_NO_UPDATE_CHECK. httpx
loads only inside the check thread.

Each check records when it ran in the cache directory
(update-check.json and plugin-update-check.json; 0600 files, replaced
atomically). The record is written just before the request goes out,
so a failed or unfinished request waits a full interval before the
next attempt, and a run that ends earlier doesn't use the interval
up. When no check is due and the saved answer names a newer version,
the notice prints from it, at most once per interval (notified_at).

The cache directory must be private: one this user owns with group
or other access (a plain mkdir under umask 002) is tightened to 0700.
If it is owned by someone else or can't be tightened, the checks
don't run, with no network and no state.

dg update keeps its last answer there too, and no longer writes the
installation method or path. The old config keys are still read
while no cache file exists, and are never written again. It shows
the installation method by its plain name.
Install detection now looks at sys.prefix, not the interpreter it
links to. A pipx or uv tool environment on Homebrew's Python resolves
its interpreter into the Cellar, which used to read as a Homebrew
install and send dg update to brew upgrade.

- Homebrew: sys.prefix under Cellar/deepgram/ only.
- uvx: sys.prefix in uv's cache under archive-v*, honoring
  UV_CACHE_DIR. The uv internal interpreter variable alone no longer
  counts, since uv run sets it for project environments too.
- pipx and pipx run: sys.prefix under pipx's venvs or .cache; a
  PIPX_HOME variable alone no longer counts.
- uv tool: sys.prefix directly under the uv tool directory; dg update
  runs uv tool install deepctl@latest, since uv tool upgrade leaves a
  pinned install where it is.
- npm: DEEPCTL_LAUNCHER=npm or npx; dg update prints
  npm i -g @deepgram/cli@latest.

The startup check skips npx runs as it does uvx and pipx run, and the
uvx advice now covers a uv tool install shadowing uvx.

dg update --dry-run prints the detected method and the command that
updates it, without checking or installing.
When neither PyPI nor, for Homebrew, the GitHub release answers,
dg update used to report the running version as the latest. It now
exits 1 with update_check_failed (E-UPDATE-CHECK, "Couldn't check for updates:
couldn't reach pypi.org.") through the coded error path. On
stderr, agent mode shows ERROR: and HINT: lines; human mode shows the
error after ✗, followed by the hint. Under -o json, stdout carries the
coded payload with details.host. The failed attempt is recorded in
the update cache file like any other.
@dg-coreylweathers

Copy link
Copy Markdown
Contributor Author

Consolidated into #165, which has all 74 commits of 0.5.0 in one PR.

@dg-coreylweathers
dg-coreylweathers deleted the release/0.5.0-3-transport branch November 7, 2026 02:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant