Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 47 additions & 17 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Cargo Build & Test
name: CI

on:
push:
Expand All @@ -8,22 +8,52 @@ env:
CARGO_TERM_COLOR: always

jobs:
build_and_test:
name: Rust project - latest
fmt:
name: Formatting
runs-on: ubuntu-latest
strategy:
matrix:
toolchain:
- stable
- beta
- nightly
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
submodules: true
- run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }}
- run: cargo install --path xtask/cargo-xtask --locked
- run: cargo xtask build
- uses: actions/checkout@v6
- name: Install pinned Rust toolchain
run: rustup show active-toolchain
- name: Check formatting
run: cargo fmt --all -- --check

clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install pinned Rust toolchain
run: rustup show active-toolchain
- name: Check lints
run: cargo clippy --workspace --all-targets -- -D warnings

test:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install pinned Rust toolchain
run: rustup show active-toolchain
- name: Run tests
run: cargo test --workspace --all-targets

docs:
name: Rustdoc
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUSTDOCFLAGS: -D warnings
steps:
- uses: actions/checkout@v6
- name: Install pinned Rust toolchain
run: rustup show active-toolchain
- name: Build documentation
run: cargo doc --workspace --no-deps

deny:
name: Dependency Policy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Check advisories, licenses, bans, and sources
uses: EmbarkStudios/cargo-deny-action@v2
11 changes: 11 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,17 @@ The config file's top-level `reasoning_effort`, and a preset's `reasoning_effort
- **WHEN** the operator passes `--reasoning-effort` and the config file or preset also sets one
- **THEN** the flag's value is used

### Requirement: Template and preset system precedence
An explicitly selected template's rendered system text overrides a selected preset's system text. An explicit `--system` argument overrides both.

#### Scenario: Template and preset both set a system message
- **WHEN** the operator selects a template with a `system` value and a preset with a `system` value
- **THEN** the rendered template system message is used

#### Scenario: Explicit system argument
- **WHEN** the operator passes `--system` while selecting a template and a preset
- **THEN** the explicit system argument is used

### Requirement: Reasoning stream separator
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.

Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ To install the llm-stream CLI tool, run:
cargo install llm-stream-cli
```

## Development

The repository pins its Rust toolchain. Install the local task wrapper, then
run the local quality checks:

```bash
cargo install --path xtask/cargo-xtask --locked
cargo xtask lint
```

CI also checks rustdoc and the dependency policy in `deny.toml`.

## Usage

### Library
Expand Down
3 changes: 3 additions & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
too-many-arguments-threshold = 5
max-fn-params-bools = 1
cognitive-complexity-threshold = 15
1 change: 0 additions & 1 deletion crates/llm_stream/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] }
clap-stdin = "0.6"
futures = "0.3.30"
spinners = "4.1.1"
atty = "0.2.14"
crossterm = "0.29.0"
config-file = "0.2.3"
tera = "1.20.0"
Expand Down
4 changes: 3 additions & 1 deletion crates/llm_stream/src/auth/listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ fn respond(socket: &mut TcpStream, status: u16, title: &str, message: &str) -> R
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::io::{Read, Write};

#[test]
fn the_preferred_port_needs_no_notice() {
Expand Down Expand Up @@ -175,6 +175,8 @@ mod tests {
"GET /auth/callback?code=xyz&state=st HTTP/1.1\r\nHost: localhost\r\n\r\n"
)
.expect("write");
let mut response = String::new();
socket.read_to_string(&mut response).expect("read response");
});
assert_eq!(listener.await_code("st").expect("await").as_str(), "xyz");
}
Expand Down
5 changes: 4 additions & 1 deletion crates/llm_stream/src/chatgpt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,10 @@ mod tests {
Api::Mistral,
Api::MistralFim,
] {
assert!(reads_api_credentials(Some(api)), "{api} lost its credential");
assert!(
reads_api_credentials(Some(api)),
"{api} lost its credential"
);
}
}

Expand Down
31 changes: 20 additions & 11 deletions crates/llm_stream/src/prelude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ pub async fn handle_stream(
) -> Result<()> {
let mut accumulated_text = String::new();

let is_terminal = atty::is(atty::Stream::Stdout);
let is_terminal = std::io::stdout().is_terminal();

// Rendering is incremental and append-only. See stream_render for why
// re-rendering the whole answer each chunk could not be made to work.
let mut renderer = crate::stream_render::StreamRenderer::new(crate::stream_render::terminal_width());
let mut renderer =
crate::stream_render::StreamRenderer::new(crate::stream_render::terminal_width());

let mut sp = if args.quiet.is_none() || (args.quiet == Some(false) && is_terminal) {
Some(spinners::Spinner::new(
Expand Down Expand Up @@ -305,7 +306,7 @@ pub fn parse_args(mut args: Args, config: Config) -> Result<(Args, Config)> {
if args.temperature.is_none() {
args.temperature = p.temperature;
}
if args.system.is_none() {
if args.system.is_none() && args.template.is_none() {
args.system = p.system;
}
if args.max_tokens.is_none() {
Expand Down Expand Up @@ -878,8 +879,14 @@ mod tests {
Some("https://chatgpt.com/backend-api/codex"),
"the endpoint this provider does read was dropped"
);
assert_eq!(actual.api_env, None, "env reached a provider that ignores it");
assert_eq!(actual.api_key, None, "key reached a provider that ignores it");
assert_eq!(
actual.api_env, None,
"env reached a provider that ignores it"
);
assert_eq!(
actual.api_key, None,
"key reached a provider that ignores it"
);
assert!(
crate::chatgpt::inert_flag_warnings(&actual).is_empty(),
"a plain invocation warned about flags the operator never typed"
Expand Down Expand Up @@ -1050,8 +1057,8 @@ mod tests {
}

#[test]
fn test_preset_system_over_config_system() -> std::result::Result<(), Box<dyn std::error::Error>>
{
fn preset_system_overrides_config_system_through_parse_pipeline(
) -> std::result::Result<(), Box<dyn std::error::Error>> {
let system = "preset system";
let config_system = "config system";
let preset_name = "preset_name";
Expand Down Expand Up @@ -1080,11 +1087,12 @@ mod tests {
..Default::default()
};

let (args, config) = parse_args(args, config)?;
let actual = merge_args_and_config(args, config)?;

assert_eq!(
expected.conversation, actual.conversation,
"The system arg should overwrite the preset system"
"The preset system should override the top-level config system"
);

Ok(())
Expand Down Expand Up @@ -1170,6 +1178,7 @@ mod tests {
..Default::default()
};

let (args, config) = parse_args(args, config)?;
let actual = merge_args_and_config(args, config)?;

assert_eq!(
Expand Down Expand Up @@ -1348,7 +1357,7 @@ content = "hello"

/// Reads back the conversation a stream handler cached under `id`.
///
/// `cargo test` captures stdout, so `atty` reports it is not a terminal and
/// `cargo test` captures stdout, so `IsTerminal` reports it is not a terminal and
/// these tests exercise the piped branch — the one that used to cache
/// nothing — without having to fake a tty.
fn cached_conversation(dir: &tempfile::TempDir, id: &str) -> Conversation {
Expand Down Expand Up @@ -1553,7 +1562,7 @@ where
T: Row + Title + 'static,
for<'a> &'a T: Row,
{
let is_terminal: bool = atty::is(atty::Stream::Stdout);
let is_terminal = std::io::stdout().is_terminal();

let table = if is_terminal {
lines.with_title()
Expand Down Expand Up @@ -1787,7 +1796,7 @@ pub async fn handle_reason_stream(
) -> Result<()> {
let mut accumulated_text = String::new();

let is_terminal = atty::is(atty::Stream::Stdout);
let is_terminal = std::io::stdout().is_terminal();
let sink = reasoning_sink(is_terminal);

// Rendering is incremental and append-only. See stream_render for why
Expand Down
3 changes: 2 additions & 1 deletion crates/llm_stream/src/printer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ use syntect::{
util::LinesWithEndings,
};

pub(crate) static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
pub(crate) static SYNTAX_SET: LazyLock<SyntaxSet> =
LazyLock::new(SyntaxSet::load_defaults_newlines);
pub(crate) static THEME: LazyLock<Theme> = LazyLock::new(|| {
let theme_data = include_str!("../assets/themes/tokyonight/tokyonight-storm.tmTheme");
ThemeSet::load_from_reader(&mut std::io::Cursor::new(theme_data)).unwrap()
Expand Down
13 changes: 7 additions & 6 deletions crates/llm_stream/src/stream_render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ pub struct Frame {
/// this never sees an escape sequence and does not have to skip one.
#[must_use]
pub fn display_width(s: &str) -> usize {
s.chars().map(|c| UnicodeWidthChar::width(c).unwrap_or(0)).sum()
s.chars()
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
.sum()
}

/// Pure. The byte offset in `s` of the first character that would carry the row
Expand Down Expand Up @@ -151,10 +153,7 @@ pub fn wrap_points(line: &str, width: usize, word_wrap: bool) -> Vec<usize> {
/// on the raw line and applied to the highlighted one, so the two can never
/// disagree about where a row ends.
#[must_use]
pub fn slice_spans<'a>(
spans: &[(Style, &'a str)],
points: &[usize],
) -> Vec<Vec<(Style, &'a str)>> {
pub fn slice_spans<'a>(spans: &[(Style, &'a str)], points: &[usize]) -> Vec<Vec<(Style, &'a str)>> {
let mut rows = Vec::new();
let mut current: Vec<(Style, &'a str)> = Vec::new();
let mut offset = 0;
Expand Down Expand Up @@ -278,7 +277,9 @@ impl StreamRenderer {
fn end_line(&mut self, frame: &mut Frame) {
let (next, delimiter) = step_mode(&self.mode, &self.line);
if !delimiter {
frame.finished.extend(self.rows().into_iter().skip(self.emitted));
frame
.finished
.extend(self.rows().into_iter().skip(self.emitted));
}
self.mode = next;
self.line.clear();
Expand Down
45 changes: 45 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
[advisories]
db-path = "~/.cargo/advisory-db"
db-urls = ["https://github.com/rustsec/advisory-db"]
ignore = [
# syntect 5.3.0 is the current release and still requires bincode 1.x.
"RUSTSEC-2025-0141",
# The pinned eventsource-client revision requires hyper-rustls 0.24, which requires this archived crate.
"RUSTSEC-2025-0134",
# This CLI validates DNS API hosts, not URI name constraints from certificates.
"RUSTSEC-2026-0098",
# This CLI validates DNS API hosts, not wildcard-name constraints from certificates.
"RUSTSEC-2026-0099",
# This client does not enable or parse certificate revocation lists.
"RUSTSEC-2026-0104",
# syntect 5.3.0 is the current release and still requires yaml-rust 0.4.
"RUSTSEC-2024-0320",
]

[licenses]
allow = [
"0BSD",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"BSL-1.0",
"CDLA-Permissive-2.0",
"ISC",
"MIT",
"OpenSSL",
"Unicode-3.0",
"Unlicense",
"Zlib",
]
confidence-threshold = 0.8

[bans]
multiple-versions = "warn"
wildcards = "allow"

[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = ["https://github.com/cloudbridgeuy/rust-eventsource-client.git"]
2 changes: 1 addition & 1 deletion lib/llm_stream/src/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pub enum Role {
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct MessageBody {
/// The model that will complete your prompt.
/// See this link for additional details and options: https://docs.anthropic.com/claude/docs/models-overview
/// See this link for additional details and options: <https://docs.anthropic.com/claude/docs/models-overview>
pub model: String,
/// Input messages.
pub messages: Vec<Message>,
Expand Down
10 changes: 8 additions & 2 deletions lib/llm_stream/src/chatgpt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -802,7 +802,10 @@ mod tests {
fn deltas_within_one_part_are_concatenated_untouched() {
// A part arrives in pieces mid-word. Inserting anything between them
// would break the word.
assert_eq!(joined(&[(0, "**Calc"), (0, "ulating"), (0, "**")]), "**Calculating**");
assert_eq!(
joined(&[(0, "**Calc"), (0, "ulating"), (0, "**")]),
"**Calculating**"
);
}

#[test]
Expand All @@ -826,7 +829,10 @@ mod tests {
// Nothing promises the first part we see is numbered 0 — a resumed or
// re-ordered stream need not start there, and "first seen" is the only
// thing that matters for the leading break.
assert_eq!(joined(&[(3, "**One**"), (4, "**Two**")]), "**One**\n\n**Two**");
assert_eq!(
joined(&[(3, "**One**"), (4, "**Two**")]),
"**One**\n\n**Two**"
);
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion lib/llm_stream/src/groq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ pub struct MessageBody {
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_sequences: Option<Vec<String>>,

/// If set, partial message deltas will be sent, like in ChatGPT. Tokens will be sent as data-only server-sent events as they become available, with the stream terminated by a data: [DONE] message.
/// If set, partial message deltas will be sent, like in ChatGPT. Tokens will be sent as data-only server-sent events as they become available, with the stream terminated by a data: \[DONE\] message.
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,

Expand Down
2 changes: 1 addition & 1 deletion lib/llm_stream/src/mistral.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub struct MessageBody {
pub stop: Option<Vec<String>>,
/// The prompt(s) to generate completions for, encoded as a list of dict with role and content.
pub messages: Vec<Message>,
/// Whether to stream back partial progress. If set, tokens will be sent as data-only server-side events as they become available, with the stream terminated by a data: [DONE] message. Otherwise, the server will hold the request open until the timeout or until completion, with the response containing the full result as JSON.
/// Whether to stream back partial progress. If set, tokens will be sent as data-only server-side events as they become available, with the stream terminated by a data: \[DONE\] message. Otherwise, the server will hold the request open until the timeout or until completion, with the response containing the full result as JSON.
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
/// The seed to use for random sampling. If set, different calls will generate deterministic results.
Expand Down
Loading