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>
258 lines
8.4 KiB
Rust
258 lines
8.4 KiB
Rust
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
|
use anyhow::Context;
|
|
use core_test_support::test_llmx_exec::test_llmx_exec;
|
|
use serde_json::Value;
|
|
use std::path::Path;
|
|
use std::string::ToString;
|
|
use uuid::Uuid;
|
|
use walkdir::WalkDir;
|
|
|
|
/// Utility: scan the sessions dir for a rollout file that contains `marker`
|
|
/// in any response_item.message.content entry. Returns the absolute path.
|
|
fn find_session_file_containing_marker(
|
|
sessions_dir: &std::path::Path,
|
|
marker: &str,
|
|
) -> Option<std::path::PathBuf> {
|
|
for entry in WalkDir::new(sessions_dir) {
|
|
let entry = match entry {
|
|
Ok(e) => e,
|
|
Err(_) => continue,
|
|
};
|
|
if !entry.file_type().is_file() {
|
|
continue;
|
|
}
|
|
if !entry.file_name().to_string_lossy().ends_with(".jsonl") {
|
|
continue;
|
|
}
|
|
let path = entry.path();
|
|
let Ok(content) = std::fs::read_to_string(path) else {
|
|
continue;
|
|
};
|
|
// Skip the first meta line and scan remaining JSONL entries.
|
|
let mut lines = content.lines();
|
|
if lines.next().is_none() {
|
|
continue;
|
|
}
|
|
for line in lines {
|
|
if line.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let Ok(item): Result<Value, _> = serde_json::from_str(line) else {
|
|
continue;
|
|
};
|
|
if item.get("type").and_then(|t| t.as_str()) == Some("response_item")
|
|
&& let Some(payload) = item.get("payload")
|
|
&& payload.get("type").and_then(|t| t.as_str()) == Some("message")
|
|
&& payload
|
|
.get("content")
|
|
.map(ToString::to_string)
|
|
.unwrap_or_default()
|
|
.contains(marker)
|
|
{
|
|
return Some(path.to_path_buf());
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Extract the conversation UUID from the first SessionMeta line in the rollout file.
|
|
fn extract_conversation_id(path: &std::path::Path) -> String {
|
|
let content = std::fs::read_to_string(path).unwrap();
|
|
let mut lines = content.lines();
|
|
let meta_line = lines.next().expect("missing meta line");
|
|
let meta: Value = serde_json::from_str(meta_line).expect("invalid meta json");
|
|
meta.get("payload")
|
|
.and_then(|p| p.get("id"))
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or_default()
|
|
.to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
|
|
let test = test_llmx_exec();
|
|
let fixture =
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
|
|
|
|
// 1) First run: create a session with a unique marker in the content.
|
|
let marker = format!("resume-last-{}", Uuid::new_v4());
|
|
let prompt = format!("echo {marker}");
|
|
|
|
test.cmd()
|
|
.env("LLMX_RS_SSE_FIXTURE", &fixture)
|
|
.env("LLMX_BASE_URL", "http://unused.local")
|
|
.arg("--skip-git-repo-check")
|
|
.arg("-C")
|
|
.arg(env!("CARGO_MANIFEST_DIR"))
|
|
.arg(&prompt)
|
|
.assert()
|
|
.success();
|
|
|
|
// Find the created session file containing the marker.
|
|
let sessions_dir = test.home_path().join("sessions");
|
|
let path = find_session_file_containing_marker(&sessions_dir, &marker)
|
|
.expect("no session file found after first run");
|
|
|
|
// 2) Second run: resume the most recent file with a new marker.
|
|
let marker2 = format!("resume-last-2-{}", Uuid::new_v4());
|
|
let prompt2 = format!("echo {marker2}");
|
|
|
|
test.cmd()
|
|
.env("LLMX_RS_SSE_FIXTURE", &fixture)
|
|
.env("LLMX_BASE_URL", "http://unused.local")
|
|
.arg("--skip-git-repo-check")
|
|
.arg("-C")
|
|
.arg(env!("CARGO_MANIFEST_DIR"))
|
|
.arg(&prompt2)
|
|
.arg("resume")
|
|
.arg("--last")
|
|
.assert()
|
|
.success();
|
|
|
|
// Ensure the same file was updated and contains both markers.
|
|
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
|
|
.expect("no resumed session file containing marker2");
|
|
assert_eq!(
|
|
resumed_path, path,
|
|
"resume --last should append to existing file"
|
|
);
|
|
let content = std::fs::read_to_string(&resumed_path)?;
|
|
assert!(content.contains(&marker));
|
|
assert!(content.contains(&marker2));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
|
|
let test = test_llmx_exec();
|
|
let fixture =
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
|
|
|
|
// 1) First run: create a session
|
|
let marker = format!("resume-by-id-{}", Uuid::new_v4());
|
|
let prompt = format!("echo {marker}");
|
|
|
|
test.cmd()
|
|
.env("LLMX_RS_SSE_FIXTURE", &fixture)
|
|
.env("LLMX_BASE_URL", "http://unused.local")
|
|
.arg("--skip-git-repo-check")
|
|
.arg("-C")
|
|
.arg(env!("CARGO_MANIFEST_DIR"))
|
|
.arg(&prompt)
|
|
.assert()
|
|
.success();
|
|
|
|
let sessions_dir = test.home_path().join("sessions");
|
|
let path = find_session_file_containing_marker(&sessions_dir, &marker)
|
|
.expect("no session file found after first run");
|
|
let session_id = extract_conversation_id(&path);
|
|
assert!(
|
|
!session_id.is_empty(),
|
|
"missing conversation id in meta line"
|
|
);
|
|
|
|
// 2) Resume by id
|
|
let marker2 = format!("resume-by-id-2-{}", Uuid::new_v4());
|
|
let prompt2 = format!("echo {marker2}");
|
|
|
|
test.cmd()
|
|
.env("LLMX_RS_SSE_FIXTURE", &fixture)
|
|
.env("LLMX_BASE_URL", "http://unused.local")
|
|
.arg("--skip-git-repo-check")
|
|
.arg("-C")
|
|
.arg(env!("CARGO_MANIFEST_DIR"))
|
|
.arg(&prompt2)
|
|
.arg("resume")
|
|
.arg(&session_id)
|
|
.assert()
|
|
.success();
|
|
|
|
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
|
|
.expect("no resumed session file containing marker2");
|
|
assert_eq!(
|
|
resumed_path, path,
|
|
"resume by id should append to existing file"
|
|
);
|
|
let content = std::fs::read_to_string(&resumed_path)?;
|
|
assert!(content.contains(&marker));
|
|
assert!(content.contains(&marker2));
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
|
|
let test = test_llmx_exec();
|
|
let fixture =
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
|
|
|
|
let marker = format!("resume-config-{}", Uuid::new_v4());
|
|
let prompt = format!("echo {marker}");
|
|
|
|
test.cmd()
|
|
.env("LLMX_RS_SSE_FIXTURE", &fixture)
|
|
.env("LLMX_BASE_URL", "http://unused.local")
|
|
.arg("--skip-git-repo-check")
|
|
.arg("--sandbox")
|
|
.arg("workspace-write")
|
|
.arg("--model")
|
|
.arg("gpt-5")
|
|
.arg("-C")
|
|
.arg(env!("CARGO_MANIFEST_DIR"))
|
|
.arg(&prompt)
|
|
.assert()
|
|
.success();
|
|
|
|
let sessions_dir = test.home_path().join("sessions");
|
|
let path = find_session_file_containing_marker(&sessions_dir, &marker)
|
|
.expect("no session file found after first run");
|
|
|
|
let marker2 = format!("resume-config-2-{}", Uuid::new_v4());
|
|
let prompt2 = format!("echo {marker2}");
|
|
|
|
let output = test
|
|
.cmd()
|
|
.env("LLMX_RS_SSE_FIXTURE", &fixture)
|
|
.env("LLMX_BASE_URL", "http://unused.local")
|
|
.arg("--skip-git-repo-check")
|
|
.arg("--sandbox")
|
|
.arg("workspace-write")
|
|
.arg("--model")
|
|
.arg("gpt-5-high")
|
|
.arg("-C")
|
|
.arg(env!("CARGO_MANIFEST_DIR"))
|
|
.arg(&prompt2)
|
|
.arg("resume")
|
|
.arg("--last")
|
|
.output()
|
|
.context("resume run should succeed")?;
|
|
|
|
assert!(output.status.success(), "resume run failed: {output:?}");
|
|
|
|
let stderr = String::from_utf8(output.stderr)?;
|
|
assert!(
|
|
stderr.contains("model: gpt-5-high"),
|
|
"stderr missing model override: {stderr}"
|
|
);
|
|
if cfg!(target_os = "windows") {
|
|
assert!(
|
|
stderr.contains("sandbox: read-only"),
|
|
"stderr missing downgraded sandbox note: {stderr}"
|
|
);
|
|
} else {
|
|
assert!(
|
|
stderr.contains("sandbox: workspace-write"),
|
|
"stderr missing sandbox override: {stderr}"
|
|
);
|
|
}
|
|
|
|
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
|
|
.expect("no resumed session file containing marker2");
|
|
assert_eq!(resumed_path, path, "resume should append to same file");
|
|
|
|
let content = std::fs::read_to_string(&resumed_path)?;
|
|
assert!(content.contains(&marker));
|
|
assert!(content.contains(&marker2));
|
|
Ok(())
|
|
}
|