aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-llm/src/client.rs
diff options
context:
space:
mode:
authorelvis <elvis@claros.ar>2026-09-26 20:20:19 -0300
committerelvis <elvis@claros.ar>2026-09-26 20:20:19 -0300
commit8518a63f55153e7f45fd49ad6caff5555f4e374f (patch)
tree636684eea3fa6f35d78282ab95eb687ef49154af /crates/asist-llm/src/client.rs
parent69de76dc9cbedc6092d1e5ce84094a8030de1470 (diff)
downloadasist-p-main.tar.gz
asist-p-main.zip
Translate code, comments, logs and terminal UI to English; add English README; rename scriptsHEADmain
Diffstat (limited to 'crates/asist-llm/src/client.rs')
-rw-r--r--crates/asist-llm/src/client.rs98
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,