A Rust library and CLI for streaming interactions with LLM providers (OpenAI, Anthropic, Google, Mistral, Ollama, Groq, Jina, DeepSeek, and a ChatGPT subscription account), including a ChatGPT sign-in flow for authenticating with a subscription instead of a metered API key.
ChatGPT sign-in: The OAuth 2.0 + PKCE browser flow that authenticates the CLI against a ChatGPT subscription account (as opposed to an OpenAI API key). Avoid: login, OAuth flow (when the subscription-specific meaning is intended)
Credentials:
The persisted TokenSet (access token, refresh token, ID token, account id) resulting from a ChatGPT sign-in, stored at <config_dir>/auth.json.
Avoid: tokens, auth file (be specific: say "credentials" for the concept, "auth.json" for the file)
ChatGPT provider:
The --api chatgpt provider (aliases chat-gpt, codex) that streams model answers using ChatGPT sign-in credentials, over OpenAI's private Responses API, rather than a metered API key.
Avoid: ChatGPT API (there is no public "ChatGPT API"; this is the undocumented Responses API used by Codex)
Summary part: One numbered block of a reasoning summary. The server numbers the parts and sends no marker at a seam, so a part boundary is only visible as the number changing. Avoid: chunk, delta (a delta is one wire message; a part spans many)
Running llm-stream --login authenticates the operator against a ChatGPT subscription account via a browser-based OAuth 2.0 + PKCE flow, and persists the resulting credentials for later use.
- WHEN the operator runs
llm-stream --login - THEN the CLI opens (or prints, for headless/SSH sessions) an
auth.openai.comauthorization URL - AND it waits on a local loopback listener for the browser to complete the flow
- AND on success it exchanges the authorization code for credentials and stores them at
<config_dir>/auth.json - AND it prints
signed in as {email} ({plan type})
- WHEN the loopback callback's
stateparameter is missing or does not match the value generated at the start of--login - THEN the sign-in fails and no credentials are stored
- WHEN the operator runs
llm-stream --loginwhile another process holds port 1455 - THEN the CLI prints a warning to stderr naming port 1455, the port it fell back to, and what to close
- AND the sign-in continues on the fallback port
Stored ChatGPT credentials are only ever readable and writable by the file's owner.
- WHEN
--loginwrites<config_dir>/auth.jsonfor the first time - THEN the file is created with mode
0600
- WHEN
--loginor a token refresh overwrites anauth.jsonthat previously had broader permissions - THEN the file's permissions are tightened back to
0600
Running llm-stream --login-status reports whether ChatGPT credentials are stored, without refreshing them.
- WHEN the operator runs
llm-stream --login-statusand credentials are stored - THEN the CLI prints
signed in as {email} ({plan type}) — access token valid for {N}s, where{N}is the remaining time until the access token'sexpclaim (zero if already expired)
- WHEN the operator runs
llm-stream --login-statusand no credentials are stored - THEN the CLI prints
not signed in — run: llm-stream --login
Running llm-stream --logout removes any stored ChatGPT credentials.
- WHEN the operator runs
llm-stream --logoutand credentials are stored - THEN the credential file is deleted and the CLI prints
signed out
- WHEN the operator runs
llm-stream --logoutand no credentials are stored - THEN the CLI still prints
signed outand does not error
Running llm-stream --api chatgpt sends the prompt, and any conversation history, to a ChatGPT subscription account's model and streams the answer to stdout, using the credentials from ChatGPT sign-in.
- WHEN the operator runs
llm-stream --api chatgpt "<prompt>"while signed in - THEN the CLI streams the model's answer to stdout
- WHEN the operator runs
llm-stream --api chatgpt --from-last "<prompt>"after an earlier--api chatgptturn in the same conversation - THEN the model's answer reflects information from that earlier turn
- WHEN the operator runs
llm-stream --api chatgpt "<prompt>"without stored ChatGPT credentials - THEN the CLI errors with
not signed in — run: llm-stream --loginand streams nothing
- WHEN the server accepts the request but then reports a failure mid-stream (for example,
server_is_overloaded), which arrives inside an otherwise successful HTTP 200 response - THEN the CLI errors with the server's own sentence (for example,
Our servers are currently overloaded. Please try again later.) and exits non-zero - AND it never reports success with an empty answer
- WHEN the operator runs
llm-stream --api chatgpt --model <model> "<prompt>"and the server refuses the request (for example, a model the account's plan cannot use) - THEN the CLI errors with the server's own message (for example,
The '<model>' model is not supported when using Codex with a ChatGPT account.) and streams nothing
- WHEN the operator runs
llm-stream --api chatgptwith--temperature,--top-p,--top-k,--api-key, or--api-env - THEN the CLI prints a warning to stderr that the flag is ignored by this provider
- AND the answer still streams to stdout as normal
- WHEN the operator runs
llm-stream --api chatgpt --reasoning-effort <low|medium|high|xhigh> "<prompt>" - THEN the request asks the model for that level of effort
- AND the answer streams to stdout as normal, with no reasoning summary
- WHEN the operator runs
llm-stream --api chatgpt --reasoning-summary "<prompt>"on a terminal - THEN the model's reasoning summary, if it produces one, streams to stdout beside the answer it precedes, syntax-highlighted the same way the answer is
- AND a
---rule is printed to stdout between the summary and the answer - AND the answer streams to stdout after it
- WHEN the operator runs the same command with stdout piped or redirected
- THEN the summary and the
---rule go to stderr as plain text, carrying no escape sequences - AND stdout holds the answer and nothing else
- WHEN the model's summary arrives as more than one numbered part
- THEN a blank line separates each part from the one before it, so no two parts share a row
- AND the first part is not preceded by a blank line
- WHEN the operator runs
llm-stream --api chatgpt --reasoning-summary "<prompt>"and the model produces no summary - THEN no
---rule is printed - AND the answer streams to stdout unchanged
- WHEN the config file or the selected preset sets
reasoning_effortto a value that is notlow,medium,high, orxhigh - THEN the CLI errors with
unknown reasoning effort "<value>"; expected one of: low, medium, high, xhighand streams nothing
The config file's top-level reasoning_effort, and a preset's reasoning_effort, supply the value when --reasoning-effort is not given.
- WHEN the operator runs
llm-stream --api chatgpt "<prompt>"without--reasoning-effort, and the config file setsreasoning_effort - THEN the configured value is used, regardless of which provider the config file's own
apinames
- WHEN the operator passes
--reasoning-effortand the config file or preset also sets one - THEN the flag's value is used
An explicitly selected template's rendered system text overrides a selected preset's system text. An explicit --system argument overrides both.
- WHEN the operator selects a template with a
systemvalue and a preset with asystemvalue - THEN the rendered template system message is used
- WHEN the operator passes
--systemwhile selecting a template and a preset - THEN the explicit system argument is used
Any provider whose stream can carry both a reasoning summary and answer text (chatgpt --reasoning-summary, and --api deepseek, which always streams reasoning) prints a --- rule between the two, and only when reasoning text actually arrived — never when the stream carried no reasoning. The rule travels on the same stream as the summary it separates: stdout on a terminal, stderr when stdout is piped, so a pipe never receives a stray rule.
- WHEN the operator runs
llm-stream --api deepseek "<prompt>" | cat - THEN stdout contains only the answer, with no leading
---
Running llm-stream --models asks the server which models the signed-in ChatGPT account may use, one at a time, and reports the answer for every candidate slug. It is a query: it succeeds whether the account can use every candidate or none of them.
- WHEN the operator runs
llm-stream --modelswhile signed in - THEN the CLI prints a warning to stderr naming how many models it will probe and that each accepted probe spends subscription quota, before opening any connection
- AND it prints a table to stdout with one row per candidate slug
- AND a slug the account may use reads
OK - AND a slug the server refuses reads the server's own sentence
- WHEN the operator runs
llm-stream --modelswithout stored ChatGPT credentials - THEN the CLI errors with
not signed in — run: llm-stream --login - AND no quota warning is printed and no model is probed
- WHEN the operator runs
llm-stream --models > models.txt - THEN
models.txtcontains only the table, and the quota warning appears on the terminal
- WHEN the operator runs
llm-stream --api chatgpt --model <slug> "<prompt>"with a slug that--modelsdoes not list - THEN the request is still sent and the server decides, exactly as if the slug had been listed
The config file's top-level base_url, env, key, version, and model defaults describe one specific provider and are only inherited when the config file's own api matches the provider selected via --api, or no --api flag was given. env and key are narrower still: a provider that signs in instead of reading an API key never inherits them, even from a config file that names it.
- WHEN the operator runs
llm-stream --api <provider> "<prompt>"and the config file's top-levelapinames a different provider - THEN
base_url,env,key,version, andmodelare not inherited from the config file - AND the provider falls back to its own built-in default endpoint and model
- WHEN the operator runs
llm-stream --api <provider> "<prompt>"and the config file's top-levelapimatches<provider>, or omits--apientirely - THEN
base_url,env,key,version, andmodelare inherited from the config file as before
- WHEN the operator runs
llm-stream --api chatgpt "<prompt>"and the config file namesapi = "chatgpt"beside anenvor akey - THEN
base_url,version, andmodelare inherited as usual - AND
envandkeyare not, so the run does not warn that--api-envor--api-keyis ignored for a flag the operator never typed
Running llm-stream --version identifies the binary and the version actually installed.
- WHEN the operator runs
llm-stream --version - THEN the CLI prints
llm-stream <version>, where<version>is the version of the installedllm-streampackage
A conversation's title, description, and parent belong to the conversation, not to the invocation that set them. Continuing a conversation rewrites its cache file wholesale, so every one of them is carried over from the cache unless the command line supplies its own.
- WHEN the operator runs
llm-stream --from <id> "<prompt>"on a conversation that has a title or a description, without passing--titleor--description - THEN the rewritten cache file still carries them
- AND
llm-stream --liststill shows them
- WHEN the operator runs
llm-stream --from <id> --title "<new title>" "<prompt>" - THEN the new title replaces the cached one
- WHEN the operator runs
llm-stream --from <id> --set-title "<new title>"or--set-description "<new description>" - THEN the selected cache file is updated and the CLI exits without contacting a provider
- AND
--set-titleand--set-descriptionmay be used together - AND comments, unknown keys, and conversation blocks in the cache file are preserved
- WHEN the operator runs
llm-stream --from <id> "<prompt>"on a conversation that was created with--fork - THEN the rewritten cache file still names the conversation it forked off, so
llm-stream --listkeeps showing its lineage
On a terminal an answer is rendered as it arrives, and the run leaves the cursor on a row of its own.
- WHEN the model's final chunk ends part-way through a line and the operator is on a terminal
- THEN the CLI closes that row before it returns, so the shell prompt appears below the answer rather than on top of its last line
What a conversation records is the same whether the operator watched the answer on a terminal or piped it somewhere. Redirecting stdout changes how the answer is rendered, never whether it is remembered.
- WHEN the operator runs
llm-stream "<prompt>" | cat, or redirects stdout to a file - THEN the answer streams to stdout unrendered, without syntax highlighting
- AND the conversation records the assistant's turn with the full answer
- WHEN the operator continues a conversation whose earlier turns were produced by piped runs
- THEN the model receives those earlier answers as history
- WHEN the operator runs
llm-stream --api deepseek "<prompt>", or--api chatgpt --reasoning-summary, on a terminal or through a pipe - THEN the conversation records the answer alone, whichever stream the reasoning summary was written to
--show prints a stored conversation without calling the model. --last narrows that to the final message alone, and is a narrowing of --show rather than a modifier on it: it prints on its own. Either way the conversation is named by --from or --from-last, and a run that cannot name one says so instead of printing nothing.
- WHEN the operator runs
llm-stream --show --from <id> - THEN the CLI prints the conversation's stored form to stdout, syntax-highlighted, and calls no model
- AND with
--no-colorit prints the same text unhighlighted
- WHEN the operator runs
llm-stream --last --from <id>, with or without--show - THEN the CLI prints only the content of the conversation's final message, and nothing else
- WHEN the operator runs
llm-stream --lastwith neither--fromnor--from-last - THEN the CLI errors with
--last needs --from or --from-last to name a conversationand exits non-zero - AND nothing reaches stdout
- WHEN the operator runs
llm-stream --last --from <id>on a conversation whose stored form holds no messages - THEN the CLI errors with a sentence naming
<id>and exits non-zero