diff options
Diffstat (limited to 'crates/asist-llm/src/client.rs')
| -rw-r--r-- | crates/asist-llm/src/client.rs | 98 |
1 files changed, 47 insertions, 51 deletions
diff --git a/crates/asist-llm/src/client.rs b/crates/asist-llm/src/client.rs index 7282947..f43ad26 100644 --- a/crates/asist-llm/src/client.rs +++ b/crates/asist-llm/src/client.rs @@ -1,4 +1,4 @@ -//! Transporte hacia llama-server. +//! Transport to llama-server. use std::time::{Duration, Instant}; @@ -11,21 +11,21 @@ use asist_core::tools::{ToolCall, ToolRegistry}; use crate::chat::Conversation; -/// Un trozo de respuesta según sale del modelo. +/// A piece of the answer as it comes out of the model. #[derive(Debug, Clone)] pub enum Delta { - /// Texto para el usuario. + /// Text for the user. Text(String), - /// El modelo ha decidido llamar a herramientas; no habrá más texto. + /// The model decided to call tools; no more text will come. ToolCalls(Vec<ToolCall>), } -/// Cómo terminó una respuesta en streaming. +/// How a streaming answer ended. #[derive(Debug, Clone, Default)] pub struct StreamOutcome { pub text: String, pub tool_calls: Vec<ToolCall>, - /// Tiempo hasta el primer fragmento con contenido. + /// Time until the first fragment with content. pub ttft: Option<Duration>, pub stopped_early: bool, } @@ -48,7 +48,7 @@ impl LlmClient { self.http.healthy("/health") } - /// Espera a que el servidor termine de cargar el modelo. + /// Waits for the server to finish loading the model. pub fn wait_ready(&self, timeout: Duration) -> Result<()> { let deadline = Instant::now() + timeout; while Instant::now() < deadline { @@ -58,18 +58,18 @@ impl LlmClient { std::thread::sleep(Duration::from_millis(500)); } Err(Error::Llm(format!( - "{} no respondió a /health en {} s", + "{} did not answer /health within {} s", self.http.authority, timeout.as_secs() ))) } - /// Comprueba que la plantilla de chat no arranca un bloque de - /// razonamiento sin cerrar. + /// Checks that the chat template does not open a reasoning block + /// without closing it. /// - /// Merece la pena avisar: con la plantilla original de Qwen3.5 el modelo - /// se pasa entre 7 y 9 segundos «pensando» antes de la primera palabra - /// audible, y desde fuera parece que el asistente se ha colgado. + /// It is worth a warning: with the original Qwen3.5 template the model + /// spends 7 to 9 seconds «thinking» before the first audible word, and from + /// the outside the assistant looks hung. pub fn warn_if_thinking_template(&self) { let Ok(body) = self.http.get("/props") else { return; @@ -86,24 +86,24 @@ impl LlmClient { if opens > closes { tracing::warn!( target: "llm", - "la plantilla de chat abre <think> sin cerrarlo: el modelo razonará \ - varios segundos antes de contestar. Arranca llama-server con \ + "the chat template opens <think> without closing it: the model will reason \ + for several seconds before answering. Start llama-server with \ --chat-template-file config/qwen35-no-think.jinja" ); } } - /// Pregunta al modelo por una imagen, en una petición aparte de la - /// conversación. + /// Asks the model about an image, in a request separate from the + /// conversation. /// - /// El servidor tiene cargado el proyector multimodal (`--mmproj`), así que - /// acepta partes `image_url` con la imagen en base64. Se hace fuera del - /// historial a propósito: una imagen ocupa cientos de tokens de contexto y - /// arrastrarla turno tras turno saldría carísimo para lo poco que aporta - /// una vez descrita. Lo que vuelve a la conversación es el texto. + /// The server has the multimodal projector loaded (`--mmproj`), so it + /// accepts `image_url` parts with the image in base64. It is kept out of the + /// history on purpose: an image takes hundreds of context tokens and dragging + /// it turn after turn would be very expensive for how little it adds once + /// described. What goes back into the conversation is the text. /// - /// Medido en esta máquina, el coste depende mucho del tamaño: 1,3 s a - /// 320x240, 2,9 s a 640x480 y 7,8 s a 1280x720. + /// Measured on this machine, the cost depends heavily on size: 1.3 s at + /// 320x240, 2.9 s at 640x480 and 7.8 s at 1280x720. pub fn look(&self, image_jpeg: &[u8], question: &str, cancel: &Cancel) -> Result<String> { use base64::Engine; let encoded = base64::engine::general_purpose::STANDARD.encode(image_jpeg); @@ -127,9 +127,9 @@ impl LlmClient { body["model"] = json!(self.config.model); } - // En streaming aunque no se use el texto parcial: una petición de - // visión tarda segundos y sin flujo el socket puede quedarse callado - // hasta pasado el plazo de lectura. + // Streaming even though the partial text is unused: a vision request + // takes seconds and without a stream the socket may stay silent past the + // read deadline. let started = Instant::now(); let mut text = String::new(); let mut finished = false; @@ -172,11 +172,11 @@ impl LlmClient { target: "llm", bytes = image_jpeg.len(), ms = started.elapsed().as_millis(), - "visión" + "vision" ); let text = text.trim().to_string(); if text.is_empty() { - return Err(Error::Llm("el modelo no describió la imagen".into())); + return Err(Error::Llm("the model did not describe the image".into())); } Ok(text) } @@ -202,10 +202,10 @@ impl LlmClient { body } - /// Envía la conversación y entrega la respuesta a trozos. + /// Sends the conversation and delivers the answer in pieces. /// - /// `on_delta` devuelve `false` para cortar —lo que hace el orquestador - /// cuando el usuario interrumpe—; `cancel` hace lo mismo desde otro hilo. + /// `on_delta` returns `false` to stop (what the orchestrator does when the + /// user interrupts); `cancel` does the same from another thread. pub fn stream( &self, chat: &Conversation, @@ -253,8 +253,8 @@ impl LlmClient { } } } - // Un `finish_reason` cierra la respuesta aunque el servidor no - // llegue a mandar el [DONE] (pasa al cortar la conexión). + // A `finish_reason` closes the answer even if the server never + // sends [DONE] (it happens when the connection is cut). if choice.get("finish_reason").is_some_and(|r| !r.is_null()) { finished = true; return false; @@ -264,8 +264,8 @@ impl LlmClient { match result { Ok(()) => {} - // Cortar a propósito no es un fallo: `on_delta` devolvió false o - // se activó la cancelación. + // Stopping on purpose is not a failure: `on_delta` returned false or + // cancellation was triggered. Err(Error::Cancelled) if finished || outcome.stopped_early || cancel.is_cancelled() => { } Err(err) => return Err(err), @@ -281,11 +281,11 @@ impl LlmClient { } } -/// Reensambla las llamadas a herramientas, que llegan repartidas en fragmentos. +/// Reassembles tool calls, which arrive split across fragments. /// -/// El streaming manda el nombre en un fragmento y los argumentos en varios -/// más, identificados sólo por su `index`; hasta que no termina el flujo no -/// hay una llamada completa que ejecutar. +/// Streaming sends the name in one fragment and the arguments in several +/// more, identified only by their `index`; until the stream ends there is +/// no complete call to run. #[derive(Default)] struct ToolCallAssembler { partial: Vec<PartialCall>, @@ -357,7 +357,7 @@ mod tests { } #[test] - fn los_argumentos_troceados_se_reensamblan() { + fn chunked_arguments_are_reassembled() { let mut assembler = ToolCallAssembler::default(); absorb( &mut assembler, @@ -375,7 +375,7 @@ mod tests { } #[test] - fn se_reensamblan_varias_llamadas_en_paralelo() { + fn several_parallel_calls_are_reassembled() { let mut assembler = ToolCallAssembler::default(); absorb( &mut assembler, @@ -392,7 +392,7 @@ mod tests { } #[test] - fn sin_argumentos_se_asume_el_objeto_vacio() { + fn no_arguments_means_an_empty_object() { let mut assembler = ToolCallAssembler::default(); absorb( &mut assembler, @@ -402,24 +402,20 @@ mod tests { } #[test] - fn un_hueco_sin_nombre_no_produce_una_llamada() { - // El servidor puede numerar el índice 1 sin haber mandado nunca el 0. + fn slot_without_a_name_produces_no_call() { + // The server may number index 1 without ever having sent 0. let mut assembler = ToolCallAssembler::default(); absorb( &mut assembler, r#"[{"index":1,"id":"b","function":{"name":"dos","arguments":"{}"}}]"#, ); let calls = assembler.finish(); - assert_eq!( - calls.len(), - 1, - "el hueco no debe convertirse en una llamada vacía" - ); + assert_eq!(calls.len(), 1, "the gap must not become an empty call"); assert_eq!(calls[0].name, "dos"); } #[test] - fn sin_id_se_genera_uno_estable() { + fn without_an_id_a_stable_one_is_generated() { let mut assembler = ToolCallAssembler::default(); absorb( &mut assembler, |