feat: Complete LLMX v0.1.0 - Rebrand from Codex with LiteLLM Integration

This release represents a comprehensive transformation of the codebase from Codex to LLMX,
enhanced with LiteLLM integration to support 100+ LLM providers through a unified API.

## Major Changes

### Phase 1: Repository & Infrastructure Setup
- Established new repository structure and branching strategy
- Created comprehensive project documentation (CLAUDE.md, LITELLM-SETUP.md)
- Set up development environment and tooling configuration

### Phase 2: Rust Workspace Transformation
- Renamed all Rust crates from `codex-*` to `llmx-*` (30+ crates)
- Updated package names, binary names, and workspace members
- Renamed core modules: codex.rs → llmx.rs, codex_delegate.rs → llmx_delegate.rs
- Updated all internal references, imports, and type names
- Renamed directories: codex-rs/ → llmx-rs/, codex-backend-openapi-models/ → llmx-backend-openapi-models/
- Fixed all Rust compilation errors after mass rename

### Phase 3: LiteLLM Integration
- Integrated LiteLLM for multi-provider LLM support (Anthropic, OpenAI, Azure, Google AI, AWS Bedrock, etc.)
- Implemented OpenAI-compatible Chat Completions API support
- Added model family detection and provider-specific handling
- Updated authentication to support LiteLLM API keys
- Renamed environment variables: OPENAI_BASE_URL → LLMX_BASE_URL
- Added LLMX_API_KEY for unified authentication
- Enhanced error handling for Chat Completions API responses
- Implemented fallback mechanisms between Responses API and Chat Completions API

### Phase 4: TypeScript/Node.js Components
- Renamed npm package: @codex/codex-cli → @valknar/llmx
- Updated TypeScript SDK to use new LLMX APIs and endpoints
- Fixed all TypeScript compilation and linting errors
- Updated SDK tests to support both API backends
- Enhanced mock server to handle multiple API formats
- Updated build scripts for cross-platform packaging

### Phase 5: Configuration & Documentation
- Updated all configuration files to use LLMX naming
- Rewrote README and documentation for LLMX branding
- Updated config paths: ~/.codex/ → ~/.llmx/
- Added comprehensive LiteLLM setup guide
- Updated all user-facing strings and help text
- Created release plan and migration documentation

### Phase 6: Testing & Validation
- Fixed all Rust tests for new naming scheme
- Updated snapshot tests in TUI (36 frame files)
- Fixed authentication storage tests
- Updated Chat Completions payload and SSE tests
- Fixed SDK tests for new API endpoints
- Ensured compatibility with Claude Sonnet 4.5 model
- Fixed test environment variables (LLMX_API_KEY, LLMX_BASE_URL)

### Phase 7: Build & Release Pipeline
- Updated GitHub Actions workflows for LLMX binary names
- Fixed rust-release.yml to reference llmx-rs/ instead of codex-rs/
- Updated CI/CD pipelines for new package names
- Made Apple code signing optional in release workflow
- Enhanced npm packaging resilience for partial platform builds
- Added Windows sandbox support to workspace
- Updated dotslash configuration for new binary names

### Phase 8: Final Polish
- Renamed all assets (.github images, labels, templates)
- Updated VSCode and DevContainer configurations
- Fixed all clippy warnings and formatting issues
- Applied cargo fmt and prettier formatting across codebase
- Updated issue templates and pull request templates
- Fixed all remaining UI text references

## Technical Details

**Breaking Changes:**
- Binary name changed from `codex` to `llmx`
- Config directory changed from `~/.codex/` to `~/.llmx/`
- Environment variables renamed (CODEX_* → LLMX_*)
- npm package renamed to `@valknar/llmx`

**New Features:**
- Support for 100+ LLM providers via LiteLLM
- Unified authentication with LLMX_API_KEY
- Enhanced model provider detection and handling
- Improved error handling and fallback mechanisms

**Files Changed:**
- 578 files modified across Rust, TypeScript, and documentation
- 30+ Rust crates renamed and updated
- Complete rebrand of UI, CLI, and documentation
- All tests updated and passing

**Dependencies:**
- Updated Cargo.lock with new package names
- Updated npm dependencies in llmx-cli
- Enhanced OpenAPI models for LLMX backend

This release establishes LLMX as a standalone project with comprehensive LiteLLM
integration, maintaining full backward compatibility with existing functionality
while opening support for a wide ecosystem of LLM providers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Sebastian Krüger <support@pivoine.art>
This commit is contained in:
Sebastian Krüger
2025-11-12 20:40:44 +01:00
parent 052b052832
commit 3c7efc58c8
1248 changed files with 10085 additions and 9580 deletions

View File

@@ -0,0 +1,12 @@
mod model;
mod render;
pub(crate) use model::CommandOutput;
#[cfg(test)]
pub(crate) use model::ExecCall;
pub(crate) use model::ExecCell;
pub(crate) use render::OutputLinesParams;
pub(crate) use render::TOOL_CALL_MAX_LINES;
pub(crate) use render::new_active_exec_command;
pub(crate) use render::output_lines;
pub(crate) use render::spinner;

View File

@@ -0,0 +1,126 @@
use std::time::Duration;
use std::time::Instant;
use llmx_protocol::parse_command::ParsedCommand;
#[derive(Clone, Debug, Default)]
pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
/// The aggregated stderr + stdout interleaved.
pub(crate) aggregated_output: String,
/// The formatted output of the command, as seen by the model.
pub(crate) formatted_output: String,
}
#[derive(Debug, Clone)]
pub(crate) struct ExecCall {
pub(crate) call_id: String,
pub(crate) command: Vec<String>,
pub(crate) parsed: Vec<ParsedCommand>,
pub(crate) output: Option<CommandOutput>,
pub(crate) is_user_shell_command: bool,
pub(crate) start_time: Option<Instant>,
pub(crate) duration: Option<Duration>,
}
#[derive(Debug)]
pub(crate) struct ExecCell {
pub(crate) calls: Vec<ExecCall>,
}
impl ExecCell {
pub(crate) fn new(call: ExecCall) -> Self {
Self { calls: vec![call] }
}
pub(crate) fn with_added_call(
&self,
call_id: String,
command: Vec<String>,
parsed: Vec<ParsedCommand>,
is_user_shell_command: bool,
) -> Option<Self> {
let call = ExecCall {
call_id,
command,
parsed,
output: None,
is_user_shell_command,
start_time: Some(Instant::now()),
duration: None,
};
if self.is_exploring_cell() && Self::is_exploring_call(&call) {
Some(Self {
calls: [self.calls.clone(), vec![call]].concat(),
})
} else {
None
}
}
pub(crate) fn complete_call(
&mut self,
call_id: &str,
output: CommandOutput,
duration: Duration,
) {
if let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) {
call.output = Some(output);
call.duration = Some(duration);
call.start_time = None;
}
}
pub(crate) fn should_flush(&self) -> bool {
!self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some())
}
pub(crate) fn mark_failed(&mut self) {
for call in self.calls.iter_mut() {
if call.output.is_none() {
let elapsed = call
.start_time
.map(|st| st.elapsed())
.unwrap_or_else(|| Duration::from_millis(0));
call.start_time = None;
call.duration = Some(elapsed);
call.output = Some(CommandOutput {
exit_code: 1,
formatted_output: String::new(),
aggregated_output: String::new(),
});
}
}
}
pub(crate) fn is_exploring_cell(&self) -> bool {
self.calls.iter().all(Self::is_exploring_call)
}
pub(crate) fn is_active(&self) -> bool {
self.calls.iter().any(|c| c.output.is_none())
}
pub(crate) fn active_start_time(&self) -> Option<Instant> {
self.calls
.iter()
.find(|c| c.output.is_none())
.and_then(|c| c.start_time)
}
pub(crate) fn iter_calls(&self) -> impl Iterator<Item = &ExecCall> {
self.calls.iter()
}
pub(super) fn is_exploring_call(call: &ExecCall) -> bool {
!call.parsed.is_empty()
&& call.parsed.iter().all(|p| {
matches!(
p,
ParsedCommand::Read { .. }
| ParsedCommand::ListFiles { .. }
| ParsedCommand::Search { .. }
)
})
}
}

View File

@@ -0,0 +1,548 @@
use std::time::Instant;
use super::model::CommandOutput;
use super::model::ExecCall;
use super::model::ExecCell;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::HistoryCell;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::shimmer::shimmer_spans;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use crate::wrapping::word_wrap_lines;
use itertools::Itertools;
use llmx_ansi_escape::ansi_escape_line;
use llmx_common::elapsed::format_duration;
use llmx_protocol::parse_command::ParsedCommand;
use ratatui::prelude::*;
use ratatui::style::Modifier;
use ratatui::style::Stylize;
use textwrap::WordSplitter;
use unicode_width::UnicodeWidthStr;
pub(crate) const TOOL_CALL_MAX_LINES: usize = 5;
const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50;
pub(crate) struct OutputLinesParams {
pub(crate) line_limit: usize,
pub(crate) only_err: bool,
pub(crate) include_angle_pipe: bool,
pub(crate) include_prefix: bool,
}
pub(crate) fn new_active_exec_command(
call_id: String,
command: Vec<String>,
parsed: Vec<ParsedCommand>,
is_user_shell_command: bool,
) -> ExecCell {
ExecCell::new(ExecCall {
call_id,
command,
parsed,
output: None,
is_user_shell_command,
start_time: Some(Instant::now()),
duration: None,
})
}
#[derive(Clone)]
pub(crate) struct OutputLines {
pub(crate) lines: Vec<Line<'static>>,
pub(crate) omitted: Option<usize>,
}
pub(crate) fn output_lines(
output: Option<&CommandOutput>,
params: OutputLinesParams,
) -> OutputLines {
let OutputLinesParams {
line_limit,
only_err,
include_angle_pipe,
include_prefix,
} = params;
let CommandOutput {
aggregated_output, ..
} = match output {
Some(output) if only_err && output.exit_code == 0 => {
return OutputLines {
lines: Vec::new(),
omitted: None,
};
}
Some(output) => output,
None => {
return OutputLines {
lines: Vec::new(),
omitted: None,
};
}
};
let src = aggregated_output;
let lines: Vec<&str> = src.lines().collect();
let total = lines.len();
let mut out: Vec<Line<'static>> = Vec::new();
let head_end = total.min(line_limit);
for (i, raw) in lines[..head_end].iter().enumerate() {
let mut line = ansi_escape_line(raw);
let prefix = if !include_prefix {
""
} else if i == 0 && include_angle_pipe {
""
} else {
" "
};
line.spans.insert(0, prefix.into());
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
out.push(line);
}
let show_ellipsis = total > 2 * line_limit;
let omitted = if show_ellipsis {
Some(total - 2 * line_limit)
} else {
None
};
if show_ellipsis {
let omitted = total - 2 * line_limit;
out.push(format!("… +{omitted} lines").into());
}
let tail_start = if show_ellipsis {
total - line_limit
} else {
head_end
};
for raw in lines[tail_start..].iter() {
let mut line = ansi_escape_line(raw);
if include_prefix {
line.spans.insert(0, " ".into());
}
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
out.push(line);
}
OutputLines {
lines: out,
omitted,
}
}
pub(crate) fn spinner(start_time: Option<Instant>) -> Span<'static> {
let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default();
if supports_color::on_cached(supports_color::Stream::Stdout)
.map(|level| level.has_16m)
.unwrap_or(false)
{
shimmer_spans("")[0].clone()
} else {
let blink_on = (elapsed.as_millis() / 600).is_multiple_of(2);
if blink_on { "".into() } else { "".dim() }
}
}
impl HistoryCell for ExecCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if self.is_exploring_cell() {
self.exploring_display_lines(width)
} else {
self.command_display_lines(width)
}
}
fn desired_transcript_height(&self, width: u16) -> u16 {
self.transcript_lines(width).len() as u16
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = vec![];
for (i, call) in self.iter_calls().enumerate() {
if i > 0 {
lines.push("".into());
}
let script = strip_bash_lc_and_escape(&call.command);
let highlighted_script = highlight_bash_to_lines(&script);
let cmd_display = word_wrap_lines(
&highlighted_script,
RtOptions::new(width as usize)
.initial_indent("$ ".magenta().into())
.subsequent_indent(" ".into()),
);
lines.extend(cmd_display);
if let Some(output) = call.output.as_ref() {
lines.extend(output.formatted_output.lines().map(ansi_escape_line));
let duration = call
.duration
.map(format_duration)
.unwrap_or_else(|| "unknown".to_string());
let mut result: Line = if output.exit_code == 0 {
Line::from("".green().bold())
} else {
Line::from(vec![
"".red().bold(),
format!(" ({})", output.exit_code).into(),
])
};
result.push_span(format!("{duration}").dim());
lines.push(result);
}
}
lines
}
}
impl ExecCell {
fn exploring_display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
out.push(Line::from(vec![
if self.is_active() {
spinner(self.active_start_time())
} else {
"".dim()
},
" ".into(),
if self.is_active() {
"Exploring".bold()
} else {
"Explored".bold()
},
]));
let mut calls = self.calls.clone();
let mut out_indented = Vec::new();
while !calls.is_empty() {
let mut call = calls.remove(0);
if call
.parsed
.iter()
.all(|parsed| matches!(parsed, ParsedCommand::Read { .. }))
{
while let Some(next) = calls.first() {
if next
.parsed
.iter()
.all(|parsed| matches!(parsed, ParsedCommand::Read { .. }))
{
call.parsed.extend(next.parsed.clone());
calls.remove(0);
} else {
break;
}
}
}
let reads_only = call
.parsed
.iter()
.all(|parsed| matches!(parsed, ParsedCommand::Read { .. }));
let call_lines: Vec<(&str, Vec<Span<'static>>)> = if reads_only {
let names = call
.parsed
.iter()
.map(|parsed| match parsed {
ParsedCommand::Read { name, .. } => name.clone(),
_ => unreachable!(),
})
.unique();
vec![(
"Read",
Itertools::intersperse(names.into_iter().map(Into::into), ", ".dim()).collect(),
)]
} else {
let mut lines = Vec::new();
for parsed in &call.parsed {
match parsed {
ParsedCommand::Read { name, .. } => {
lines.push(("Read", vec![name.clone().into()]));
}
ParsedCommand::ListFiles { cmd, path } => {
lines.push(("List", vec![path.clone().unwrap_or(cmd.clone()).into()]));
}
ParsedCommand::Search { cmd, query, path } => {
let spans = match (query, path) {
(Some(q), Some(p)) => {
vec![q.clone().into(), " in ".dim(), p.clone().into()]
}
(Some(q), None) => vec![q.clone().into()],
_ => vec![cmd.clone().into()],
};
lines.push(("Search", spans));
}
ParsedCommand::Unknown { cmd } => {
lines.push(("Run", vec![cmd.clone().into()]));
}
}
}
lines
};
for (title, line) in call_lines {
let line = Line::from(line);
let initial_indent = Line::from(vec![title.cyan(), " ".into()]);
let subsequent_indent = " ".repeat(initial_indent.width()).into();
let wrapped = word_wrap_line(
&line,
RtOptions::new(width as usize)
.initial_indent(initial_indent)
.subsequent_indent(subsequent_indent),
);
push_owned_lines(&wrapped, &mut out_indented);
}
}
out.extend(prefix_lines(out_indented, "".dim(), " ".into()));
out
}
fn command_display_lines(&self, width: u16) -> Vec<Line<'static>> {
let [call] = &self.calls.as_slice() else {
panic!("Expected exactly one call in a command display cell");
};
let layout = EXEC_DISPLAY_LAYOUT;
let success = call.output.as_ref().map(|o| o.exit_code == 0);
let bullet = match success {
Some(true) => "".green().bold(),
Some(false) => "".red().bold(),
None => spinner(call.start_time),
};
let title = if self.is_active() {
"Running"
} else if call.is_user_shell_command {
"You ran"
} else {
"Ran"
};
let mut header_line =
Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()]);
let header_prefix_width = header_line.width();
let cmd_display = strip_bash_lc_and_escape(&call.command);
let highlighted_lines = highlight_bash_to_lines(&cmd_display);
let continuation_wrap_width = layout.command_continuation.wrap_width(width);
let continuation_opts =
RtOptions::new(continuation_wrap_width).word_splitter(WordSplitter::NoHyphenation);
let mut continuation_lines: Vec<Line<'static>> = Vec::new();
if let Some((first, rest)) = highlighted_lines.split_first() {
let available_first_width = (width as usize).saturating_sub(header_prefix_width).max(1);
let first_opts =
RtOptions::new(available_first_width).word_splitter(WordSplitter::NoHyphenation);
let mut first_wrapped: Vec<Line<'static>> = Vec::new();
push_owned_lines(&word_wrap_line(first, first_opts), &mut first_wrapped);
let mut first_wrapped_iter = first_wrapped.into_iter();
if let Some(first_segment) = first_wrapped_iter.next() {
header_line.extend(first_segment);
}
continuation_lines.extend(first_wrapped_iter);
for line in rest {
push_owned_lines(
&word_wrap_line(line, continuation_opts.clone()),
&mut continuation_lines,
);
}
}
let mut lines: Vec<Line<'static>> = vec![header_line];
let continuation_lines = Self::limit_lines_from_start(
&continuation_lines,
layout.command_continuation_max_lines,
);
if !continuation_lines.is_empty() {
lines.extend(prefix_lines(
continuation_lines,
Span::from(layout.command_continuation.initial_prefix).dim(),
Span::from(layout.command_continuation.subsequent_prefix).dim(),
));
}
if let Some(output) = call.output.as_ref() {
let line_limit = if call.is_user_shell_command {
USER_SHELL_TOOL_CALL_MAX_LINES
} else {
TOOL_CALL_MAX_LINES
};
let raw_output = output_lines(
Some(output),
OutputLinesParams {
line_limit,
only_err: false,
include_angle_pipe: false,
include_prefix: false,
},
);
let display_limit = if call.is_user_shell_command {
USER_SHELL_TOOL_CALL_MAX_LINES
} else {
layout.output_max_lines
};
if raw_output.lines.is_empty() {
lines.extend(prefix_lines(
vec![Line::from("(no output)".dim())],
Span::from(layout.output_block.initial_prefix).dim(),
Span::from(layout.output_block.subsequent_prefix),
));
} else {
let trimmed_output = Self::truncate_lines_middle(
&raw_output.lines,
display_limit,
raw_output.omitted,
);
let mut wrapped_output: Vec<Line<'static>> = Vec::new();
let output_wrap_width = layout.output_block.wrap_width(width);
let output_opts =
RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation);
for line in trimmed_output {
push_owned_lines(
&word_wrap_line(&line, output_opts.clone()),
&mut wrapped_output,
);
}
if !wrapped_output.is_empty() {
lines.extend(prefix_lines(
wrapped_output,
Span::from(layout.output_block.initial_prefix).dim(),
Span::from(layout.output_block.subsequent_prefix),
));
}
}
}
lines
}
fn limit_lines_from_start(lines: &[Line<'static>], keep: usize) -> Vec<Line<'static>> {
if lines.len() <= keep {
return lines.to_vec();
}
if keep == 0 {
return vec![Self::ellipsis_line(lines.len())];
}
let mut out: Vec<Line<'static>> = lines[..keep].to_vec();
out.push(Self::ellipsis_line(lines.len() - keep));
out
}
fn truncate_lines_middle(
lines: &[Line<'static>],
max: usize,
omitted_hint: Option<usize>,
) -> Vec<Line<'static>> {
if max == 0 {
return Vec::new();
}
if lines.len() <= max {
return lines.to_vec();
}
if max == 1 {
// Carry forward any previously omitted count and add any
// additionally hidden content lines from this truncation.
let base = omitted_hint.unwrap_or(0);
// When an existing ellipsis is present, `lines` already includes
// that single representation line; exclude it from the count of
// additionally omitted content lines.
let extra = lines
.len()
.saturating_sub(usize::from(omitted_hint.is_some()));
let omitted = base + extra;
return vec![Self::ellipsis_line(omitted)];
}
let head = (max - 1) / 2;
let tail = max - head - 1;
let mut out: Vec<Line<'static>> = Vec::new();
if head > 0 {
out.extend(lines[..head].iter().cloned());
}
let base = omitted_hint.unwrap_or(0);
let additional = lines
.len()
.saturating_sub(head + tail)
.saturating_sub(usize::from(omitted_hint.is_some()));
out.push(Self::ellipsis_line(base + additional));
if tail > 0 {
out.extend(lines[lines.len() - tail..].iter().cloned());
}
out
}
fn ellipsis_line(omitted: usize) -> Line<'static> {
Line::from(vec![format!("… +{omitted} lines").dim()])
}
}
#[derive(Clone, Copy)]
struct PrefixedBlock {
initial_prefix: &'static str,
subsequent_prefix: &'static str,
}
impl PrefixedBlock {
const fn new(initial_prefix: &'static str, subsequent_prefix: &'static str) -> Self {
Self {
initial_prefix,
subsequent_prefix,
}
}
fn wrap_width(self, total_width: u16) -> usize {
let prefix_width = UnicodeWidthStr::width(self.initial_prefix)
.max(UnicodeWidthStr::width(self.subsequent_prefix));
usize::from(total_width).saturating_sub(prefix_width).max(1)
}
}
#[derive(Clone, Copy)]
struct ExecDisplayLayout {
command_continuation: PrefixedBlock,
command_continuation_max_lines: usize,
output_block: PrefixedBlock,
output_max_lines: usize,
}
impl ExecDisplayLayout {
const fn new(
command_continuation: PrefixedBlock,
command_continuation_max_lines: usize,
output_block: PrefixedBlock,
output_max_lines: usize,
) -> Self {
Self {
command_continuation,
command_continuation_max_lines,
output_block,
output_max_lines,
}
}
}
const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new(
PrefixedBlock::new("", ""),
2,
PrefixedBlock::new("", " "),
5,
);