feat(workspace): initialize Rust workspace structure for v2
Set up Cargo workspace with initial crates: - cli: main application entry point with chat streaming tests - config: configuration management - llm/ollama: Ollama client integration with NDJSON support Includes .gitignore for Rust and JetBrains IDEs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
22
crates/llm/ollama/.gitignore
vendored
Normal file
22
crates/llm/ollama/.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
/target
|
||||
### Rust template
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug/
|
||||
target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
### rust-analyzer template
|
||||
# Can be generated by other build systems other than cargo (ex: bazelbuild/rust_rules)
|
||||
rust-project.json
|
||||
|
||||
|
||||
16
crates/llm/ollama/Cargo.toml
Normal file
16
crates/llm/ollama/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "llm-ollama"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
tokio = { version = "1.39", features = ["rt-multi-thread"] }
|
||||
futures = "0.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "1"
|
||||
bytes = "1"
|
||||
tokio-stream = "0.1.17"
|
||||
84
crates/llm/ollama/src/client.rs
Normal file
84
crates/llm/ollama/src/client.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use crate::types::{ChatMessage, ChatResponseChunk};
|
||||
use futures::{Stream, TryStreamExt};
|
||||
use reqwest::Client;
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OllamaClient {
|
||||
http: Client,
|
||||
base_url: String, // e.g. "http://localhost:11434"
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct OllamaOptions {
|
||||
pub model: String,
|
||||
pub stream: bool,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum OllamaError {
|
||||
#[error("http: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("protocol: {0}")]
|
||||
Protocol(String),
|
||||
}
|
||||
|
||||
impl OllamaClient {
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
http: Client::new(),
|
||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_cloud() -> Self {
|
||||
// Same API, different base
|
||||
Self::new("https://ollama.com")
|
||||
}
|
||||
|
||||
pub async fn chat_stream(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
opts: &OllamaOptions,
|
||||
) -> Result<impl Stream<Item = Result<ChatResponseChunk, OllamaError>>, OllamaError> {
|
||||
#[derive(Serialize)]
|
||||
struct Body<'a> {
|
||||
model: &'a str,
|
||||
messages: &'a [ChatMessage],
|
||||
stream: bool,
|
||||
}
|
||||
let url = format!("{}/api/chat", self.base_url);
|
||||
let body = Body {model: &opts.model, messages, stream: true};
|
||||
let resp = self.http.post(url).json(&body).send().await?;
|
||||
let bytes_stream = resp.bytes_stream();
|
||||
|
||||
// NDJSON parser: split by '\n', parse each as JSON and stream the results
|
||||
let out = bytes_stream
|
||||
.map_err(OllamaError::Http)
|
||||
.map_ok(|bytes| {
|
||||
// Convert the chunk to a UTF‑8 string and own it
|
||||
let txt = String::from_utf8_lossy(&bytes).into_owned();
|
||||
// Parse each non‑empty line into a ChatResponseChunk
|
||||
let results: Vec<Result<ChatResponseChunk, OllamaError>> = txt
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
serde_json::from_str::<ChatResponseChunk>(trimmed)
|
||||
.map_err(OllamaError::Json),
|
||||
)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
futures::stream::iter(results)
|
||||
})
|
||||
.try_flatten(); // Stream<Item = Result<ChatResponseChunk, OllamaError>>
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
5
crates/llm/ollama/src/lib.rs
Normal file
5
crates/llm/ollama/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod client;
|
||||
pub mod types;
|
||||
|
||||
pub use client::{OllamaClient, OllamaOptions};
|
||||
pub use types::{ChatMessage, ChatResponseChunk};
|
||||
22
crates/llm/ollama/src/types.rs
Normal file
22
crates/llm/ollama/src/types.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: String, // "user", | "assistant" | "system"
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ChatResponseChunk {
|
||||
pub model: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub message: Option<ChunkMessage>,
|
||||
pub done: Option<bool>,
|
||||
pub total_duration: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ChunkMessage {
|
||||
pub role: Option<String>,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
12
crates/llm/ollama/tests/ndjson.rs
Normal file
12
crates/llm/ollama/tests/ndjson.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use llm_ollama::{OllamaClient, OllamaOptions};
|
||||
|
||||
// This test stubs NDJSON by spinning a tiny local server is overkill for M0.
|
||||
// Instead, test the line parser indirectly by mocking reqwest is complex.
|
||||
// We'll smoke-test the client type compiles and leave end-to-end to cli tests.
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_compiles_smoke() {
|
||||
let _ = OllamaClient::new("http://localhost:11434");
|
||||
let _ = OllamaClient::with_cloud();
|
||||
let _ = OllamaOptions { model: "qwen2.5".into(), stream: true };
|
||||
}
|
||||
Reference in New Issue
Block a user