Skip to content

0.5.0 (2/5): login for agents, and listen, speak and debug streaming - #161

Closed
dg-coreylweathers wants to merge 18 commits into
release/0.5.0-1-startupfrom
release/0.5.0-2-streaming
Closed

dg-coreylweathers wants to merge 18 commits into
release/0.5.0-1-startupfrom
release/0.5.0-2-streaming

Conversation

@dg-coreylweathers

Copy link
Copy Markdown
Contributor

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

dg login accepts real API keys and asks for --yes instead of cancelling, and agents can finish device login with --complete. Live dg listen finishes cleanly on Ctrl-C and end of input with bounded memory; dg speak streams audio as it arrives and gains --out-file (-o/--output for the audio file is deprecated). Agent mode drops status chrome from stderr. dg debug network sends no stored credentials, debug browser and probe run on websockets, and numpy, ffmpeg-python and requests are dropped.

Commits

  • 0f71836 fix(login): accept real API keys and ask for --yes instead of cancelling
  • 1f4dc37 feat(login): let agents finish device login with --complete
  • 16aeca8 perf(core): drop the pre-flight key check and reuse one HTTP client
  • 648576e fix(output): drop status chrome from stderr in agent mode
  • 9a8a9c2 fix(listen): finish live streams cleanly on Ctrl-C and end of input
  • b57d046 perf(listen): bound live-stream memory and frame latency
  • 2735cd9 fix(listen): keep machine output to the final result
  • 531f595 perf(speak): stream audio to pipes and players as it arrives
  • cd70665 feat(speak): add --out-file and deprecate -o/--output for the audio file
  • 0524904 fix(speak): don't read stdin or write audio to a pipe in agent mode
  • 6b8907d fix(listen): report a failed stdout write as an output error
  • ad6f9f6 fix(cli): treat Ctrl-C while the CLI is loading as cancelled
  • 9f34dbc perf(listen): stream prerecorded uploads from disk
  • 773cef5 feat(listen): add --timeout for prerecorded transcription
  • 7c39a56 perf(listen): check URL sources without fetching them
  • 3a2af7b fix(debug): send no stored credentials from debug network
  • b45f74d build(deps): drop numpy, ffmpeg-python and requests from commands
  • 747f195 refactor(debug): run debug browser and probe on websockets

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

dg login no longer checks for an sk-/pk- prefix: Deepgram keys don't
have one, and login_with_api_key already verifies the key with the API.
A 40-hex key used to stop with "Login cancelled" whenever no one could
answer the "Continue anyway?" prompt.

The two login confirmations (DEEPGRAM_API_KEY is set; the profile
already has a saved login) are asked only of a person at a terminal. In
any other run they return confirmation_required, exit 1, with
details.flag "--yes" and a next_step that reruns the command with
--yes, or null when the command line holds a key.
They no longer return "cancelled", which is kept for a person saying no.
-y/--yes answers both; -f/--force-write stays as the same
flag.

BaseCommand gains can_prompt(), the check confirm() already made, so a
command can tell "no one can answer" from "the answer was no".
In an AI agent session (or with DG_AGENT=1, --no-browser, or neither
stdin nor stdout a terminal), dg login now asks for a device code,
saves it and returns at once with the link and code (status "pending",
exit 0). dg login --complete checks once: auth_pending
with details.retry_after until the user approves, then saves the key
as before; auth_denied or auth_expired otherwise, and auth_expired with
next_step "dg login" when no login is waiting. --wait keeps 0.4.0's
behavior of polling until done; a person at a terminal still gets it
by default.

The pending code lives in {state_dir}/login-pending.json: written 0600
through O_CREAT|O_EXCL|O_NOFOLLOW and an atomic replace, in a 0700
directory, holding the device code, interval, expiry, login host and
profile, never a key. state_dir() is ~/.deepctl, the one state-dir
helper later changes reuse. In an AI agent session the file must be
owned by this user with no group or other write bit; otherwise it is
ignored with one WARN: line and left in place. A code is only sent back
to the login host that issued it.

Before any request, login checks that it could save the result: an
unwritable state or config directory is state_unwritable
(E-STATE-UNWRITABLE); in an AI agent session, a user config dg won't
save over is state_unwritable with details.reason "untrusted_config".
Both come before the --yes confirmations, and both apply
to --api-key and --complete too. A home dg can't write no longer stops
every command while Config picks its default path.

The token poll is a POST to the RFC 8628 token endpoint, so the device
code never lands in a URL, and a fixed device name replaces this
machine's hostname. Polls are at least 6 s apart whatever the server
asks; slow_down, 429 and server errors back off by 5 s or the server's
Retry-After, and --wait keeps polling through them until the code
expires. A device-code answer that isn't a JSON object is
network_unavailable. The login link must be https (or loopback http).

Status lines go to stderr. In mode A they are dropped, and the link and
code are an INFO: line; a person's terminal shows the same line instead
of the separate code and link lines. --complete refuses --profile for
another profile than the pending login's, and --save-base-url. The
legacy config migration prints to stderr.
Every authed command listed projects before its real request, twice
(once in BaseCommand.execute and again when the SDK client was built),
on a second TCP connection. That cost two round trips per command and
31 s on a stalled network.

- guard() checks only that a key is present. The host check still runs
  first, before any key is read, and the missing-key (auth_required) and
  E_KEY_UNTRUSTED paths are unchanged. The removed check's error text
  ("Network error during verification: ...", "Your API key may have
  expired or been revoked.") is gone.
- New deepctl_core.transport: one httpx.Client per process, created on
  first use and passed to the SDK as httpx_client. It carries no base
  URL, no credentials and keeps no cookies; DeepgramClient still takes
  the base URL from hosts.check_credential_host(). Its timeout matches
  the SDK's own client (60 s). It follows no redirects, as 0.4.1's SDK
  client doesn't, and a 3xx keeps 0.4.1's refusal.
- Request failures map to the catalogue's transport_mapping codes: DNS,
  connect, TLS, reset, a proxy refusal, a cut-off body (E-NETWORK-CUT)
  or a 2xx body that won't parse (E-NETWORK-UNREADABLE) ->
  network_unavailable; timeout -> timeout; 401 auth_invalid; 403
  auth_forbidden; 404 not_found; 429 rate_limited; 400, 422, other 4xx
  invalid_argument; 5xx server_error. The SDK's own retries still run
  first. Other failures keep their 0.4.0 wording.
- BaseCommand renders a mapped failure as two stderr lines (ERROR:/HINT:
  in mode A, a cross and the hint otherwise) and, under a machine
  format, a coded result on stdout with code, hint, next_step,
  schema_version 1 and details; exit 1. details.key_source follows dg
  whoami's order. The 0.4.1 coded results keep their keys.
- dg speak marks stdout as its payload stream once audio starts, so a
  failure mid-stream goes to stderr only, never as JSON after the audio.
- projects --set-default still warns and asks when the key can't see the
  project (404/403); other request failures stop it.

The 0.4.1 host tests that used the pre-flight as their probe now send
the command's own request through the shared client.
In mode A every stderr line carries a prefix and
status chrome is suppressed. The commands that use the shared client
(billing, keys, listen, members, models, projects, read, requests,
speak, transcribe, usage) now run their handler with stderr filtered:
only ERROR:/WARN:/INFO:/HINT:/OK:/DEPRECATED: lines, and 0.4.0
"Error"/"Warning" lines, get through. Each write is kept or dropped as a
whole. --verbose keeps everything, and every other command (login's
device code, dg api, debug-probe) is unchanged.

Lines in those commands that a caller needs now render as warnings or
errors instead of chrome, so they survive the filter: listen's Flux
--diarize note, its --encoding assumption and the microphone audio
warning; speak's --play note; projects' "Failed to ..." errors and the
missing project_id warning.

The listen and speak warnings print under --quiet too (print_warning
gains ignore_quiet), since --quiet hides only INFO and OK lines.
Live stdin and microphone streams run through one session engine
(deepctl_cmd_listen.live) with defined endings:

- Ctrl-C (Ctrl-Break on Windows): send CloseStream, keep receiving for
  at most 1 s, keep the finals and --save-to, exit 2. On --mic, Ctrl-C
  is how the session ends: the same drain, then exit 0 with the
  transcript so far, also when it comes before the session has
  connected. Ctrl-Break stays a hard stop (exit 2) on --mic. A second
  Ctrl-C exits at once. The handler is signal.signal, not
  add_signal_handler, so it works on every platform and on Python 3.10.
- SIGTERM on POSIX (docker stop, systemd, Kubernetes): the same drain,
  then the process re-raises SIGTERM with the default action, so the
  wait status stays "killed by signal 15" (143 in a shell). A second
  signal ends it at once.
- End of input: send CloseStream and wait for the server's close under
  an idle deadline (10 s since the last server message) and an absolute
  cap (max(30 s, 2 x the audio still behind for PCM/mulaw/alaw, or
  2 x the time spent sending otherwise)). If either expires the run ends
  with stream_drain_timeout, exit 1. Ctrl-C during the drain switches to
  the 1 s cap and exit 2.
- A signal that arrives while the server is closing still counts: the
  run ends as stopped by that signal.
- The server closes: exit 0, or exit 1 with server_error when it
  reported an error.
- The connection drops: exit 1 with network_unavailable.
- Whoever reads stdout goes away (`| head -n 1`, `| true`): the stream
  ends quietly and exits 0. Captions are flushed line by line, so they
  also arrive as they are produced.
- stdin can't be read (a write-only or broken descriptor, EIO on a
  hang-up): exit 1 with invalid_argument and a hint to pass a file or
  URL instead.

Each failure is a coded error whose details carry the finals received
so far (transcript, transcript_chars, saved_to), so nothing that
arrived is lost. Text the server sends back (close reasons, Flux error
descriptions) is stripped of control characters and of the API key and
any Token/Bearer value before it is shown or saved.

Stdin is read with os.read in a daemon thread that hands each buffer to
the loop through run_coroutine_threadsafe and waits for it to be taken.
The thread checks a closing event before every read and hand-off,
shutdown sets that event before it cancels any task, and anything raised
in the thread ends it quietly. This removes the interpreter abort (exit
134, "Fatal Python error") that a reader blocked in sys.stdin.buffer
caused at shutdown. The reader blocks SIGINT and SIGTERM so they always
reach the main thread, and waits on a stdin left non-blocking by its
parent instead of failing. Regular files, pipes, /dev/null and TTYs all
work.

At interpreter exit the remaining objects are frozen out of the garbage
collector, so the process ends a few milliseconds after the server's
close instead of about 100 ms later.

The session opens through deepctl_core.websocket.open_async, the same
no-redirect open as connect_async but returning the connection and
taking websockets options, so a 3xx on the upgrade is still refused
before a second connection (mic and stdin), as in 0.4.1. deepctl-core
now declares websockets>=13.1,<18, which that module imports.
- Stdin reads take at most 50 ms of audio, computed from the encoding,
  sample rate and channels (whole samples; 4 KiB for encodings without a
  known byte rate). A pipe read returns what is there, so paced input
  goes up at the writer's pace: 20 ms frames at 16 kHz linear16 and at
  8 kHz mulaw, where 4 KiB reads held telephony audio for about 500 ms.
- The send queue holds at most 32 buffers. The reader waits for room,
  so a slow uplink holds back the producer instead of filling memory.
- The microphone callback can't wait, so when the queue is full the
  oldest buffer is dropped and counted; one warning per stream reports
  how much audio was dropped. Mic blocks are 50 ms.
- listen v1 streams send a KeepAlive after 5 s without audio, so a
  paused producer doesn't time the stream out.
- The WebSocket is opened with compression=None: audio doesn't compress,
  and deflate cost CPU on every frame.
When JSON or YAML was asked for (-o json, -o yaml) or chosen because an
AI-agent marker is set, a live stream writes no transcript lines,
interim rewrites or live captions to stdout. The final Result object is
the one document on stdout, whatever the stream's status, so
`dg -o json listen < call.raw | jq` parses. With --verbose, final lines
go to stderr as INFO: lines. --save-to still writes the transcript or
captions.

JSON implied only by the TTY/CI heuristics (cron, CI, a container with
no TERM) keeps 0.4.0's live output: lines as they arrive, then the
final Result, because those pipelines read stdout as a stream. To tell
the cases apart, deepctl_core.output gains format_source(): "explicit",
"hard_agent", "soft" or "default". Human output is unchanged.

Under a hard AI-agent signal, `dg listen` with no SOURCE and no --mic is
refused before authentication with invalid_argument (E-LISTEN-NO-SOURCE,
exit 1) instead of streaming a non-TTY stdin until an end of input that
may never come. `dg listen -` reads stdin explicitly. CI and people keep
auto-detection.
Speak v1 wrote each chunk to stdout without flushing, so a pipe saw
nothing until 8 KB had piled up (about 640 ms with mp3-sized chunks),
and --play buffered the whole utterance before starting the player.
Each chunk is now written and flushed as the SDK yields it, and --play
with a stdin player (ffplay, paplay, aplay) starts the player before the
request and feeds it chunk by chunk, as Flux already did. afplay keeps
the buffered temp-file path.

When the reader of stdout or the player goes away (EPIPE), the stream
stops, the response is closed and the command ends quietly with exit 0;
fd 1 is pointed at the null device so the exit flush can't fail. Any
other stdout write error (ENOSPC, EIO, EFBIG, EDQUOT) is the new coded
error output_unwritable: exit 1, two stderr lines, no envelope on
stdout and no "Exception ignored". The same holds when the result
document itself can't be written to stdout: EPIPE exits 0 quietly, any
other error is output_unwritable.

Closing a stream now waits at most 2 s. A WebSocket closing handshake
the server doesn't answer, as on Ctrl-C mid-stream, used to hold the
exit for up to 10 s; the close is abandoned after 2 s instead.
--out-file FILE writes the audio to a file without ever replacing one by
accident. Before the request, the target is checked with lstat: an
existing file or symlink is refused (E-OUT-FILE-EXISTS) unless --force
is given, and a directory, a block device, a socket, stderr itself, a
--force over a /dev/fd entry, or a missing or unwritable parent is
output_unwritable, so nothing is sent or billed.

A character device or FIFO is written as it is, with no --force. It is
opened before the request without blocking, so a FIFO with no reader is
refused rather than waited on, and the opened file must be the one that
was checked: a path swapped for a regular file in the meantime is never
written.

A new file is opened when the first chunk arrives, with O_EXCL as the
race guard, so a failed request, a cut stream or an empty one leaves no
file; a partial file is removed on any later error, and a failed write
is output_unwritable with the envelope on stdout. --force writes a 0600
temp file in the same directory and replaces the target at the end,
keeping an existing file's permission bits (never setuid, setgid or
sticky), so a symlink is replaced rather than written through. Flux
still buffers its WAV so the header carries the exact length.

During the request and stream, SIGTERM and SIGHUP remove the partial or
temp file (an old file is kept) and then end the process by the signal;
Ctrl-C does the same cleanup and cancels as before. If the unwind
stalls, as in a WebSocket close the server doesn't answer, the signal is
delivered again after 4 s and ends the run. A signal that was ignored
when dg started (nohup, a background job, trap '') stays ignored and the
file is completed. Signals that arrive while the file is created,
committed or removed are held until that step is done; the most severe
of them is then delivered. On Windows, dg never calls os.kill on its own
process.

When --out-file is stdout itself (/dev/stdout), the audio is the only
thing written there (fd 1 is checked again, not reopened) and the
summary goes to stderr; an explicit -o json
or -o yaml is refused before the request (E-STDOUT-BYTES).

-o/--output FILE keeps working with one DEPRECATED line per process,
printed even under -q, and now replaces the file through a temp file, so
a failed run leaves the old file intact. A bare format value (-o json)
is refused with a runnable next_step, and -o together with --out-file is
refused. Help, examples and the legacy hints now name --out-file.

Changes to -o FILE for the changelog: a symlink at FILE is replaced, not
written through; the directory holding FILE must be writable; and FILE
becomes a new file with the same permission bits (setuid, setgid and
sticky are dropped), so hard links to it keep the old bytes.
Under a hard AI-agent signal, dg speak with no text argument and no
--file read stdin until it closed, which can be never, and audio bound
for a pipe was written into whatever an agent's tool call was capturing.

With a hard signal, stdin is now read only when the text is - ; anything
else fails at once with E-SPEAK-NO-TEXT. Audio that would go to a stdout
that isn't a terminal, a regular file or /dev/null, with no output file,
--play or --list-voices, fails with E-SPEAK-NO-OUTPUT. Its next_step
reruns the same command with --out-file and a new file name, and is null
(with the matching hint) when the text came from stdin or the command
line holds a secret. Both refusals come before the request.

- as the text reads stdin in every mode. CI and people keep 0.4.0's
behavior otherwise.

The speak tests' person fixture lives in the root conftest, scoped to
speak's tests: a second tests/unit/conftest.py would clash with dg mcp's
when CI collects both.
When stdout couldn't be written during a live stream (dg listen - >
/dev/full), the receive loop took the ENOSPC for a dropped connection:
the run printed a network hint, then failed again flushing stdout at
exit and ended with "Exception ignored" and exit 120.

A stdout write error other than EPIPE, in the receive loop, while
flushing Flux's last turns, or while writing the final result, is now
output_unwritable: fd 1 is pointed at the null device, the error and
hint go to stderr, the finals received so far travel in details, and the
run exits 1. EPIPE still ends quietly. A failed write of a coded error
also silences stdout, so the exit flush can't fail.

The stdin catch-all now takes only read errors; an error of any other
kind is reported without the stdin hint.

Live listen now takes die_by_signal from deepctl_core.signals, the one
copy speak also uses.
A Ctrl-C in the first few hundred milliseconds, while main() imports
the CLI, reached the top level as KeyboardInterrupt and printed a
traceback with exit 1. It is now a cancelled run, like a Ctrl-C once
run() has started: the same "Operation cancelled by user" line on
stderr, exit 2, and no traceback.

main() also replaces Python's default SIGINT handler with one that
holds a Ctrl-C back for a moment (at most 200 ms) when it lands in
importlib's own code. A KeyboardInterrupt raised there can be swallowed
and leave the import lock held, so the run then hangs on its next
import.
dg listen <file> read the whole file into memory before sending it, so
a 500 MB file peaked at 1.6 GB RSS. The file now goes to the SDK as a
FileBody (deepctl_core/upload.py) with an explicit Content-Length, read
in 1 MiB chunks as httpx sends them. httpx sends no chunked encoding.

- FileBody opens the file once and keeps the descriptor. Each pass
  reads from offset 0 of that descriptor, so the SDK's retries (connect
  errors, a connection dropped before the response, 408/409/429/5xx)
  resend the whole file, and a file swapped in at the same path between
  retries is never sent.
- The file validated is the file uploaded: listen takes the file's
  (st_dev, st_ino) before validating it, and FileBody compares the
  opened descriptor against it. A path swapped in between stops with
  E-UPLOAD-FILE-CHANGED before anything is sent.
- Before each chunk, and after the last, the descriptor's size and
  mtime are compared with the values at open. A change stops the upload
  with invalid_argument E-UPLOAD-FILE-CHANGED; a failed read with
  E-UPLOAD-FILE-UNREADABLE. Both print the two stderr lines and the
  coded result a transport error does, with next_step null and
  details.path.
- A pipe or device (dg listen <(...) --no-validate) is read once at
  open, as before, so a retry can resend it.
- The descriptor closes when the request finishes.
A prerecorded request used the shared client's 60 s timeout, so a large
file, or a server that takes longer than a minute to answer, failed
with a timeout even though the upload was fine.

- dg listen <file|URL> --timeout SECONDS (at least 1, default 300) sets
  the request's timeout through the SDK's request_options. Every other
  command keeps 60 s.
- E-TIMEOUT's details.timeout_s, and the "didn't respond within" text,
  report the effective value: transport.map_exception and raise_for
  take a keyword-only timeout_s.
- --timeout with --mic, - or piped stdin is a usage error; live streams
  have no request timeout.
Before every URL transcription, dg sent its own HEAD request (and a
ranged GET when HEAD failed) to the URL, then printed the whole URL,
query string included, on stderr and in the result. A presigned URL
carries its token in the query string.

- A URL source is checked by format only: an http or https scheme and
  a host, with no network access. --check-url opts in to the HEAD
  check. It is a usage error with a file or live source, or with
  --no-validate.
- The --check-url request carries no API key and keeps no cookies,
  including across its own redirects. Its GET fallback is streamed and
  reads no body, so a server that ignores Range can't fill memory.
- The "Transcribing" line, the result table and URL errors show
  scheme://host/.../<last path segment>, without user info, query
  string or fragment (validation.display_url): some hosts put a token
  in the path. The result's source_path keeps the path and drops the
  rest (validation.output_url). The request to Deepgram still gets the
  full URL. A URL passed where a file path was expected is shown the
  same way in "File not found" errors.
- A URL whose user info can't be told apart safely (a password with an
  unescaped /, ? or #, an empty host, a bad port, or an @ outside the
  parsed host) is shown as scheme://<redacted>/... everywhere, the
  result's source_path included. Error texts drop the user info and
  query of the URL they quote.
- The scheme match is case-insensitive: HTTPS://... is a URL source.
- httpx loads only when --check-url runs.
- The listen README describes URL sources, --check-url and what
  source_path keeps.

Changelog:

**`dg listen <URL>`**: in `-o json`/`yaml` output, `source_path` is the
URL without its user info, query string or fragment; the request to
Deepgram still uses the full URL. URL sources are checked by format
only; pass `--check-url` to check that the URL responds (a HEAD request
that carries no API key or cookies).
debug network now makes its diagnostic requests with httpx instead of
requests and urllib3, one short-lived client per request.

- The probe no longer attaches credentials from ~/.netrc. requests added
  them for a matching (or default) netrc entry, including on the request
  made with certificate verification disabled; httpx does not read
  netrc. No Deepgram key was ever sent by these requests, and none is
  now.
- CA bundles from the environment keep working: SSL_CERT_FILE or
  SSL_CERT_DIR first (read by httpx), then REQUESTS_CA_BUNDLE, then
  CURL_CA_BUNDLE, as requests resolved them. verify=False ignores all of
  them. HTTP(S)_PROXY and NO_PROXY apply as before.
- The JSON payload keeps every key and adds two:
  environment_info.httpx_version, and library ("httpx") on each entry of
  python_requests_tests. requests_version and urllib3_version are now
  read from package metadata.
Live listen captures the microphone as raw int16 with sounddevice
alone, without numpy.

debug audio now runs ffprobe itself, with the argv ffmpeg-python built
(-show_format -show_streams -of json), instead of through ffmpeg-python.
Both the standard probe and --ffprobe-args run from an argv list with no
shell, stdin closed and a 30 s timeout. The input is opened through the
file protocol only (-protocol_whitelist file), and a name that starts
with "-" is passed as "./-...". The JSON output and the failure messages
are unchanged.

The listen, debug-audio and debug-network packages no longer declare
numpy, ffmpeg-python (and with it future) or requests; the root package
keeps requests until 1.0, and a plugin that relied on numpy or
ffmpeg-python arriving with deepctl must now declare it itself.
debug browser:

Serve the page and the /ws socket from websockets.asyncio.server instead
of aiohttp, and declare websockets>=13.1,<18. The server binds 127.0.0.1
only and checks every request before the handshake: the Host must be
this listener, the page and the socket need the per-run ?t= token, and
the socket needs the page's own Origin (without the port on port 80, as
browsers send it). Refusals are fixed text. The page is served with
no-store, nosniff, frame-deny and no-referrer headers, and no Server
header.

The listener is bound before the URL is shown, so the URL is the one
being served, now http://127.0.0.1:PORT/?t=... A frame that isn't a
valid message is dropped instead of ending the connection, binary frames
are ignored, and what the page can make the command keep is bounded:
messages of up to 64 KiB, at most 1,000 and 8 MiB of them as stored, and
only the 12 known capability names; anything else is counted and noted
after the results. Text from the page is printed as escaped ASCII, never
as markup. Server logs go to a silenced logger.

The command's output, including the page link, now goes to stderr; under
-o json or yaml, stdout holds only the result.

Tests use real loopback servers instead of aiohttp mocks.

debug probe:

Run the probe proxy on websockets.asyncio (server and client) instead of
aiohttp, and declare websockets>=13.1,<18. Frames are relayed as they
arrive, so text stays text and binary stays binary, up to 4 MiB each way
as before. When either side closes, the other direction is cancelled,
the final probe and summary run, and the client is closed with 1000.

The proxy accepts connections only from local pages and native clients.
Checked before the handshake and before any upstream connection, the
Host must name this listener (without the port on port 80) and an
Origin, if sent, must be a page on this machine. The target must start
with "/". A refused Origin prints one line. The key is only sent in the
upstream Authorization header; client headers and subprotocols are not
forwarded, and the client and server loggers are silenced. Text from
requests is printed as escaped ASCII, and transcripts with their
Unicode kept, never as markup.

ffprobe snapshots run as asyncio subprocesses, so they no longer hold up
other connections or Ctrl+C. On POSIX, Ctrl+C is handled on the event loop:
the first stops listening, stops relaying (frames read after it
are counted as dropped) and lets open connections print their summaries;
a second closes them without waiting for close handshakes, stops a
running ffprobe and skips the final probe. A SIGINT that was ignored
when the command started stays ignored, and the previous handler is put
back on exit. The upstream URL base is an attribute, so tests point it
at a loopback server.

With websockets 15 or later, the upstream connection follows
HTTPS_PROXY and NO_PROXY, as listen and the CLI's HTTP requests do.

The upstream opens through deepctl_core.websocket.open_async, so a
redirect on the upgrade is still refused before any request to the new
location ("Upstream connection refused", no audio sent); the aiohttp
trace hook that did this goes with aiohttp.

debug browser and debug probe now run on websockets, so neither needs
aiohttp. Nothing else did, so it leaves the install with the seven dists
only it pulled in: aiohappyeyeballs, aiosignal, async-timeout,
frozenlist, multidict, propcache and yarl (about 11 MiB on
linux/amd64).

uv.lock is regenerated with uv 0.13.6. The eight names leave the
dependency allowlist, and aiohttp joins the dists that must not come
back.
@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-2-streaming branch November 7, 2026 02:42
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