Skip to main content

drep/llm/client/
mod.rs

1//! The LLM client.
2//!
3//! One boundary: `LlmClient::complete_json` takes a system prompt and a user
4//! payload, sends them to the configured OpenAI-compatible endpoint, and
5//! returns the JSON value the model produced. Cache and concurrency limiting
6//! live above this single-provider request boundary.
7//!
8//! ## What is deliberately delegated to the SDK
9//!
10//! - **Streaming.** `open-agent-sdk` parses the SSE stream; drep concatenates
11//!   the `ContentBlock::Text` blocks it emits and ignores the rest. Since
12//!   0.10.0 those blocks are *fragments* - one event per delta, delivered
13//!   while the stream is open, where 0.9.x emitted the whole response as a
14//!   single block at the end. The types are identical either way, so nothing
15//!   here failed to compile and nothing here changed: the join in
16//!   `run_one_query` is what makes the assembled text independent of where
17//!   the deltas fall. Reading one block as the whole answer would now return a
18//!   prefix, and `src/llm/client/tests/streaming.rs` is what would notice.
19//! - **Transport retry.** `retry_with_backoff_conditional` decides per error
20//!   whether to retry (5xx, timeout, stream error) or fail fast (4xx, config
21//!   errors). drep adds no retry layer on top.
22//!
23//! ## What this module owns
24//!
25//! - **Parse retry.** The same prompt truncates the same way, so a parse
26//!   failure does NOT retry. The retry closure returns `Ok(None)` for an
27//!   unparseable body; the SDK's retry sees `Ok(...)` and stops.
28//! - **Attempt count floor.** `LlmConfig::max_retries` may be 0, but a
29//!   "zero attempts loop" would skip the request and report a bogus "no
30//!   exception was captured". The floor is 1.
31//! - **`max_tokens` pass-through.** The configured cap is forwarded only when
32//!   the user set one. open-agent-sdk 0.7.0 omits the field entirely otherwise,
33//!   so "unset" means the server decides - which is what a 256k-1M context
34//!   model needs. (Before 0.7.0 the builder substituted 4096 and truncated
35//!   reasoning models mid-thought; drep passed a large sentinel to work around
36//!   it. That workaround is gone.)
37
38use std::collections::BTreeMap;
39use std::time::Duration;
40
41use futures::StreamExt;
42use open_agent::retry::{RetryConfig, retry_with_backoff_conditional};
43use open_agent::{AgentOptions, ApiProtocol, ContentBlock, FinishReason, StreamEvent, query};
44
45use crate::config::LlmConfig;
46use crate::llm::error::LlmError;
47use crate::llm::json_parsing::{Extracted, extract_json};
48use crate::text::excerpt;
49
50/// How much of a model response reaches an error message.
51///
52/// Generous: unlike a URL, the useful signal in a refusal or a prose preamble
53/// is often a sentence or two in.
54const RESPONSE_EXCERPT_MAX: usize = 200;
55
56/// How much of a transport error reaches the report.
57///
58/// For an HTTP failure the SDK's error carries the endpoint's whole response
59/// body, read without a bound, and that text is the endpoint's rather than
60/// drep's. 400 matches what the Codex backend keeps of its own reported error,
61/// so the two backends' failure blocks read alike.
62const TRANSPORT_EXCERPT_MAX: usize = 400;
63
64/// A configured LLM client ready to issue requests.
65///
66/// Built once per process from `LlmConfig`; `complete_json` is the only
67/// entry point the analyzer uses.
68///
69/// Fields are `pub(crate)` so the test submodules can construct clients
70/// with a non-default retry config (the production default sleeps 1s
71/// between attempts, which would make the retry tests take seconds). They
72/// are not part of the public API.
73pub struct LlmClient {
74    pub(crate) base_url: String,
75    pub(crate) model: String,
76    pub(crate) api_key: String,
77    /// The wire protocol this endpoint speaks. Selects the request path, the auth
78    /// header, the body shape and the streaming vocabulary together - the SDK
79    /// resolves all four from this one value.
80    pub(crate) protocol: ApiProtocol,
81    /// `None` sends no `temperature` at all. Two of the four models drep ships a
82    /// preset for reject the parameter outright, and a 400 neither fails over nor
83    /// retries, so "omit it" had to be expressible rather than approximated by a
84    /// low value.
85    pub(crate) temperature: Option<f32>,
86    /// `None` means "no ceiling": since open-agent-sdk 0.7.0 an unset
87    /// `max_tokens` is omitted from the request entirely and the server decides.
88    /// Before 0.7.0 the builder substituted 4096, which truncated reasoning
89    /// models mid-thought, and drep had to pass a large sentinel instead.
90    pub(crate) max_tokens: Option<u32>,
91    pub(crate) timeout_secs: u64,
92    /// Exactly the headers this client sends, default included.
93    ///
94    /// The effective set rather than the configured one, resolved once in
95    /// [`crate::config::effective_headers`]. Applying the default inside the
96    /// request instead left three readers disagreeing about the same question:
97    /// a config naming one header printed one here and in `doctor` while the
98    /// request carried two, and a config naming none printed an empty set while
99    /// the request still carried a `User-Agent`. That is the divergence
100    /// `auth::source_of` exists to prevent one module over.
101    pub(crate) headers: BTreeMap<String, String>,
102    /// Precomputed because every reviewed file asks for the same provider's
103    /// cache identity. It may depend on credential-bearing header values, so it
104    /// is deliberately omitted from `Debug`.
105    request_identity: String,
106    pub(crate) retry_config: RetryConfig,
107}
108
109/// Hand-written so the API key cannot reach a log.
110///
111/// A derived `Debug` prints every field, so any `{:?}`, `dbg!` or tracing line
112/// touching the client would emit a live credential. The base URL is one too
113/// when it carries `user:password@`, so it prints through
114/// [`crate::auth::display_endpoint`].
115impl std::fmt::Debug for LlmClient {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        f.debug_struct("LlmClient")
118            .field("base_url", &crate::auth::display_endpoint(&self.base_url))
119            .field("model", &self.model)
120            .field("api_key", &"<redacted>")
121            .field("protocol", &self.protocol.as_str())
122            .field("temperature", &self.temperature)
123            .field("max_tokens", &self.max_tokens)
124            .field("timeout_secs", &self.timeout_secs)
125            // Names only, spelled exactly as `LlmConfig`'s `Debug` spells it. A
126            // header value is as likely to be a credential as `api_key` is, and
127            // this impl exists precisely so no `{:?}` emits one.
128            .field(
129                "headers",
130                &self.headers.keys().map(String::as_str).collect::<Vec<_>>(),
131            )
132            .finish()
133    }
134}
135
136impl LlmClient {
137    /// The model this client asks for.
138    ///
139    /// An accessor rather than a second copy on the caller: the cache key is
140    /// computed from the model, and a struct holding its own `model` string is
141    /// exactly what lets a request go to one model while the key names
142    /// another.
143    pub fn model(&self) -> &str {
144        &self.model
145    }
146
147    /// The base URL this client talks to. For display - `doctor` and the
148    /// failover report name the endpoint a provider used.
149    pub fn endpoint(&self) -> &str {
150        &self.base_url
151    }
152
153    /// The sampling temperature, or `None` when none is sent. Part of the cache
154    /// key, for the same reason [`Self::model`] is - and `None` has to key
155    /// differently from any value, because the answers genuinely differ.
156    pub fn temperature(&self) -> Option<f32> {
157        self.temperature
158    }
159
160    /// Every HTTP request option that can change the answer while the endpoint
161    /// and model stay the same.
162    ///
163    /// Header names are canonicalised to lower case because HTTP compares them
164    /// case-insensitively. Values remain exact: an arbitrary header can select a
165    /// tenant, model route or feature variant, and drep cannot infer from its
166    /// name whether changing it changes the answer. The returned digest is fed
167    /// into the cache hash and is never logged.
168    pub fn request_identity(&self) -> &str {
169        &self.request_identity
170    }
171
172    /// The wire protocol this client speaks. For display, and for the cache key:
173    /// the same model at the same endpoint over two protocols is two requests.
174    pub fn protocol(&self) -> ApiProtocol {
175        self.protocol
176    }
177
178    /// Build a client from a validated `LlmConfig`.
179    ///
180    /// Returns [`LlmError::NotConfigured`] when the config does not name an
181    /// endpoint, a model, or has `enabled = false`. We do not default an
182    /// endpoint - "LLM was disabled" and "LLM is enabled but misconfigured"
183    /// are both fatal here, and inventing a value would mask a broken
184    /// install.
185    pub fn new(cfg: &LlmConfig) -> Result<Self, LlmError> {
186        if !cfg.enabled {
187            return Err(LlmError::NotConfigured(
188                "LLM is disabled in config (set `enabled = true`)".to_string(),
189            ));
190        }
191        let endpoint = cfg.endpoint.clone().ok_or_else(|| {
192            LlmError::NotConfigured("LLM endpoint is not set in config".to_string())
193        })?;
194        let model = cfg
195            .model
196            .clone()
197            .ok_or_else(|| LlmError::NotConfigured("LLM model is not set in config".to_string()))?;
198
199        // The SDK deliberately omits protocol authentication for an empty key.
200        // This is what lets a gateway authenticate entirely through a custom
201        // header rather than receiving a fabricated `Bearer not-needed` or
202        // `x-api-key: not-needed` beside its real credential.
203        let api_key = cfg.api_key.clone().unwrap_or_default();
204
205        // `config::load` already rejected an unknown name, so this cannot fail for a
206        // config that came through the loader. It is re-checked rather than unwrapped
207        // because `LlmClient::new` is also reachable from tests that build an
208        // `LlmConfig` directly, and a silent default here would post
209        // chat-completions bytes to a `/messages` endpoint.
210        let protocol = crate::config::parse_protocol(cfg.protocol.as_deref()).ok_or_else(|| {
211            LlmError::NotConfigured(format!(
212                "unknown protocol `{}`; expected `openai` or `anthropic`",
213                cfg.protocol.as_deref().unwrap_or_default()
214            ))
215        })?;
216
217        // max_retries is a total attempt count; the floor is 1 so a config of
218        // 0 still performs exactly one attempt. See the spec's note on the
219        // bogus "no exception was captured" failure.
220        let max_attempts = cfg.max_retries.max(1);
221
222        let headers = crate::config::effective_headers(&cfg.headers);
223        let request_identity = build_request_identity(protocol, cfg.max_tokens, &headers)?;
224
225        Ok(LlmClient {
226            base_url: endpoint,
227            model,
228            api_key,
229            protocol,
230            temperature: cfg.temperature,
231            max_tokens: cfg.max_tokens,
232            timeout_secs: cfg.timeout_secs,
233            headers,
234            request_identity,
235            retry_config: RetryConfig {
236                max_attempts,
237                initial_delay: Duration::from_secs(1),
238                max_delay: Duration::from_secs(60),
239                backoff_multiplier: 2.0,
240                jitter_factor: 0.1,
241            },
242        })
243    }
244
245    /// Send one prompt and return the extracted JSON.
246    ///
247    /// Concatenates `ContentBlock::Text` blocks in arrival order; other block
248    /// variants are ignored. An empty response body is **retried** as a
249    /// transport failure and, if it keeps coming back empty, surfaces as
250    /// [`LlmError::Transport`] - see `run_one_query` for why "the model
251    /// returned nothing" is not the deterministic outcome it looks like.
252    ///
253    /// A non-empty body that yields **no JSON at all** is retried up to
254    /// [`NO_JSON_ATTEMPTS`] times and then becomes [`LlmError::Unparseable`],
255    /// carrying an excerpt of what actually came back. A body that parsed only
256    /// after brace-balancing ([`Extracted::Truncated`]) is returned
257    /// immediately and never retried - that is the genuinely deterministic
258    /// case, and the one the "never retry" rule was written for.
259    pub async fn complete_json(
260        &self,
261        system_prompt: &str,
262        user_content: &str,
263    ) -> Result<Extracted, LlmError> {
264        // Build options per request. The SDK doesn't expose a way to set
265        // `system_prompt` after `build()`, and the retry closure needs to
266        // borrow the same options across attempts.
267        let builder = AgentOptions::builder()
268            .model(&self.model)
269            .base_url(&self.base_url)
270            .api_key(&self.api_key)
271            .system_prompt(system_prompt)
272            .protocol(self.protocol)
273            .timeout(self.timeout_secs);
274
275        // Only send a temperature when one was configured. An unset value means the
276        // field is omitted entirely, which is the only thing that works against a
277        // model that rejects the parameter.
278        let builder = match self.temperature {
279            Some(temperature) => builder.temperature(temperature),
280            None => builder,
281        };
282
283        // Only set a ceiling when the user asked for one. open-agent-sdk 0.7.0
284        // omits `max_tokens` from the request when the setter is never called,
285        // so "unset" genuinely means "let the server decide" rather than the
286        // implicit 4096 earlier versions substituted.
287        let builder = match self.max_tokens {
288            Some(limit) => builder.max_tokens(limit),
289            None => builder,
290        };
291
292        // No ordering rule here, and that is the point: `self.headers` is
293        // already the effective set, resolved once when the client was built, so
294        // this loop cannot get the precedence between drep's own `User-Agent`
295        // and an operator's replacement for it wrong.
296        let mut builder = builder;
297        for (name, value) in &self.headers {
298            builder = builder.header(name, value);
299        }
300
301        let options = builder
302            .build()
303            .map_err(|e| LlmError::NotConfigured(format!("AgentOptions build failed: {e}")))?;
304
305        let prompt = user_content.to_string();
306
307        // The no-JSON retry is drep's own loop, deliberately *outside* the
308        // SDK's. Handing "no JSON" to the SDK by returning `Err` would work,
309        // but it would surface as `LlmError::Transport` once the attempts ran
310        // out - and `Transport` fails over to the next provider and demotes
311        // this one for the whole run. A model that answered in prose has told
312        // us nothing about the endpoint: after these response retries the
313        // chain may ask a fallback for this file, but must not demote this
314        // provider.
315        //
316        // The SDK's own retry still runs inside each pass, so a transport
317        // failure is handled by the layer that classifies it.
318        let mut last_body = String::new();
319        for _ in 0..NO_JSON_ATTEMPTS {
320            let result: open_agent::Result<Answer> =
321                retry_with_backoff_conditional(self.retry_config.clone(), || {
322                    self.run_one_query(&prompt, &options)
323                })
324                .await;
325
326            match result {
327                Ok(Answer::Parsed(extracted)) => return Ok(extracted),
328                // The server said why it stopped, and the reason rules out a
329                // retry: the request hit a limit, so the same request hits the
330                // same limit. This is the genuinely deterministic case the
331                // original "never retry a non-empty body" rule was reaching
332                // for - it just used "no JSON in the body" as the proxy, which
333                // is not the same question.
334                Ok(Answer::NoJson { text, finish }) if !worth_asking_again(&finish) => {
335                    return Err(LlmError::ModelStopped {
336                        finish: finish.as_str().to_owned(),
337                        message: stopped_message(&finish, &text),
338                    });
339                }
340                Ok(Answer::NoJson { text, .. }) => last_body = text,
341                Err(e) => {
342                    // The SDK exposes the status code separately (via
343                    // `status_code`); reading it before formatting means the
344                    // number survives as a number, and a later caller can
345                    // branch on it rather than parsing the message.
346                    //
347                    // The message goes through `excerpt` like every other
348                    // piece of endpoint text. For a non-success status it is
349                    // the response body verbatim: a hostile or compromised
350                    // endpoint chooses every byte, and the failure block
351                    // prints it to the terminal, once for every file the
352                    // demoted provider then skips.
353                    let status = e.status_code();
354                    let message = excerpt(&e.to_string(), TRANSPORT_EXCERPT_MAX);
355                    return Err(LlmError::Transport { status, message });
356                }
357            }
358        }
359
360        Err(LlmError::Unparseable(format!(
361            "no JSON in the response after {NO_JSON_ATTEMPTS} attempts; \
362             the model answered: {}",
363            excerpt(&last_body, RESPONSE_EXCERPT_MAX)
364        )))
365    }
366
367    /// One attempt: stream the response, concatenate text, parse.
368    ///
369    /// Returns [`Answer::NoJson`] carrying the raw text when the query
370    /// produced something we could not parse at all - the SDK's retry layer
371    /// sees `Ok` and stops, leaving the decision to `complete_json`. Returns
372    /// `Err(SdkError)` for a transport-level failure, including an unexplained
373    /// empty response. An empty response with a terminal `Length` or
374    /// `ContentFilter` reason stays [`Answer::NoJson`], because the reason says
375    /// the same request cannot benefit from a retry.
376    async fn run_one_query(
377        &self,
378        prompt: &str,
379        options: &AgentOptions,
380    ) -> open_agent::Result<Answer> {
381        let mut stream = query(prompt, options).await?;
382        let mut text = String::new();
383        // `Unspecified` is the right default rather than a panic-if-absent:
384        // several OpenAI-compatible servers never report a reason at all, and
385        // "no information" is a distinct answer from "stopped normally".
386        let mut finish = FinishReason::Unspecified;
387        while let Some(event) = stream.next().await {
388            match event? {
389                // Image, ToolUse, ToolResult are not used here.
390                StreamEvent::Block(ContentBlock::Text(t)) => text.push_str(&t.text),
391                StreamEvent::Finish(reason) => finish = reason,
392                // Everything else is discarded, and that is the contract:
393                // `text` holds assistant text and nothing else. It covers the
394                // non-text blocks drep has no use for, the `Reasoning` side
395                // channel (opt-in, and drep does not opt in - chain-of-thought
396                // must never reach the text drep parses as JSON), and any
397                // variant a later SDK adds, since `StreamEvent` is
398                // `#[non_exhaustive]`. Spelled as one arm because a separate
399                // `Reasoning(_) => {}` above it does the same nothing, and an
400                // arm indistinguishable from the wildcard is dead code.
401                _ => {}
402            }
403        }
404
405        // An empty body is a **transport** failure, not a parse failure.
406        //
407        // An empty response is provider flakiness, not a deterministic parse
408        // failure for the prompt. Repeating the same request can immediately
409        // succeed with findings.
410        //
411        // `Error::stream` is classified retryable by the SDK, which is both
412        // accurate (the stream completed carrying no content) and nearly free:
413        // a response with no output tokens cost nothing to produce, so asking
414        // again is cheap. A *non-empty* body we cannot parse still returns
415        // `Ok(None)` and still does not retry - that is the deterministic case
416        // the split was built for, and re-sending it burns a full reasoning
417        // call for the same answer.
418        if text.trim().is_empty() && worth_asking_again(&finish) {
419            return Err(open_agent::Error::stream(
420                "the model returned an empty response",
421            ));
422        }
423
424        Ok(match extract_json(&text) {
425            Some(extracted) => Answer::Parsed(extracted),
426            // The text is carried out rather than dropped. It was discarded
427            // behind the constant "response contained no parseable JSON",
428            // which made every occurrence of this failure look identical and
429            // left no way to tell a refusal from a prose preamble from
430            // reasoning that leaked into the content channel.
431            None => Answer::NoJson { text, finish },
432        })
433    }
434}
435
436/// Build the fixed digest nested inside the response-cache key.
437///
438/// Length-prefixing through the cache's own encoder handles arbitrary UTF-8
439/// header values without reserving an escaping byte. Only the digest is kept;
440/// the framed credential-bearing text is never stored or logged.
441fn build_request_identity(
442    protocol: ApiProtocol,
443    max_tokens: Option<u32>,
444    headers: &BTreeMap<String, String>,
445) -> Result<String, LlmError> {
446    use crate::llm::cache::write_field;
447
448    let mut identity = blake3::Hasher::new();
449    write_field(&mut identity, protocol.as_str().as_bytes());
450    match max_tokens {
451        Some(limit) => {
452            write_field(&mut identity, b"set");
453            write_field(&mut identity, &limit.to_be_bytes());
454        }
455        None => write_field(&mut identity, b"unset"),
456    }
457
458    let mut canonical_headers = headers
459        .iter()
460        .map(|(name, value)| {
461            let canonical =
462                reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
463                    LlmError::NotConfigured(format!("header name `{name}` cannot be encoded"))
464                })?;
465            Ok((canonical, value))
466        })
467        .collect::<Result<Vec<_>, LlmError>>()?;
468    canonical_headers.sort_by(|left, right| left.0.as_str().cmp(right.0.as_str()));
469    let header_count = u64::try_from(canonical_headers.len())
470        .expect("the process cannot hold more than u64::MAX headers");
471    write_field(&mut identity, &header_count.to_be_bytes());
472    for (name, value) in canonical_headers {
473        write_field(&mut identity, name.as_str().as_bytes());
474        write_field(&mut identity, value.as_bytes());
475    }
476    Ok(identity.finalize().to_hex().to_string())
477}
478
479/// What one query produced, before the retry decision.
480///
481/// `NoJson` carries the body so the failure can be diagnosed and so
482/// `complete_json` can decide whether to ask again. The SDK's retry layer
483/// treats both variants as success and stops, which is what keeps the
484/// no-JSON decision here rather than inside it.
485enum Answer {
486    Parsed(Extracted),
487    /// No JSON at all, with why generation stopped. The reason decides whether
488    /// asking again can possibly help.
489    NoJson {
490        text: String,
491        finish: FinishReason,
492    },
493}
494
495/// How many times a response carrying no JSON at all is asked for again.
496///
497/// Not the same question as the SDK's transport retry, and deliberately a
498/// small number: each attempt is a full reasoning call. The rule this replaced
499/// never retried, justified as "the same prompt truncates the same way" - but
500/// that is [`Extracted::Truncated`], a different branch. A response with *no
501/// JSON at all* did not truncate an answer, it never produced one, and in
502/// practice it does not repeat: drep's own gated push failed on a different
503/// file each run, and each failing file analyzed cleanly when asked again.
504///
505/// Three total attempts, so two local retries before the provider chain may
506/// ask a fallback. Production output has been visibly garbled twice in a row
507/// and then parsed unchanged on a later run; the third attempt salvages that
508/// case without demoting an otherwise healthy provider.
509pub const NO_JSON_ATTEMPTS: u32 = 3;
510
511/// Whether asking the same question again could produce a different answer.
512///
513/// `false` for the reasons that are a property of the *request*: a token cap is
514/// hit identically every time, and a content filter that refused this payload
515/// refuses it again. `true` where the server told us nothing useful, because a
516/// model at temperature above zero can simply answer differently - which is
517/// what drep's own gated push demonstrated, failing on a different file each
518/// run with every failing file analyzing cleanly when asked again.
519fn worth_asking_again(finish: &FinishReason) -> bool {
520    // Written as a negated match on the two request-shaped reasons rather than
521    // as an enumeration of the rest. `FinishReason` is `#[non_exhaustive]`, so
522    // a wildcard arm is required either way - and an enumerated "everything
523    // else is retryable" arm sitting above it is behaviourally identical to the
524    // wildcard, which makes it undeletable-but-unobservable: exactly the dead
525    // code the mutation gate exists to find.
526    //
527    // The consequence of the wildcard is deliberate: a reason a later SDK adds
528    // defaults to retrying. The retry is bounded and cheap to be wrong about,
529    // whereas refusing to retry something transient fails a commit outright.
530    !matches!(
531        finish,
532        // A token cap is hit identically every time - drep sends no
533        // `max_tokens`, so the cap is the server's. A content filter that
534        // refused this payload refuses it again.
535        FinishReason::Length | FinishReason::ContentFilter
536    )
537}
538
539/// A sentence a user can act on, for the reasons that end the attempt.
540///
541/// The two cases want different actions - one is "this file is too big for this
542/// model in one pass", the other is "this provider refused the content" - so
543/// they do not share a message.
544fn stopped_message(finish: &FinishReason, text: &str) -> String {
545    match finish {
546        FinishReason::Length => format!(
547            "the model hit its output token limit before producing any JSON. \
548             This file is too large for this model to review in one request - \
549             split it, or use a provider with a larger output budget. \
550             It managed: {}",
551            excerpt(text, RESPONSE_EXCERPT_MAX)
552        ),
553        _ => format!(
554            "the model stopped ({}) before producing any JSON: {}",
555            finish.as_str(),
556            excerpt(text, RESPONSE_EXCERPT_MAX)
557        ),
558    }
559}
560
561#[cfg(test)]
562mod tests;