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//! arrive in Phase 3b; this phase owns the request itself.
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::time::Duration;
39
40use futures::StreamExt;
41use open_agent::retry::{RetryConfig, retry_with_backoff_conditional};
42use open_agent::{AgentOptions, ApiProtocol, ContentBlock, FinishReason, StreamEvent, query};
43
44use crate::config::LlmConfig;
45use crate::llm::error::LlmError;
46use crate::llm::json_parsing::{Extracted, extract_json};
47use crate::text::excerpt;
48
49/// How much of a model response reaches an error message.
50///
51/// Generous: unlike a URL, the useful signal in a refusal or a prose preamble
52/// is often a sentence or two in.
53const RESPONSE_EXCERPT_MAX: usize = 200;
54
55/// A configured LLM client ready to issue requests.
56///
57/// Built once per process from `LlmConfig`; `complete_json` is the only
58/// entry point the analyzer uses.
59///
60/// Fields are `pub(crate)` so the test submodules can construct clients
61/// with a non-default retry config (the production default sleeps 1s
62/// between attempts, which would make the retry tests take seconds). They
63/// are not part of the public API.
64pub struct LlmClient {
65 pub(crate) base_url: String,
66 pub(crate) model: String,
67 pub(crate) api_key: String,
68 /// The wire protocol this endpoint speaks. Selects the request path, the auth
69 /// header, the body shape and the streaming vocabulary together - the SDK
70 /// resolves all four from this one value.
71 pub(crate) protocol: ApiProtocol,
72 /// `None` sends no `temperature` at all. Two of the four models drep ships a
73 /// preset for reject the parameter outright, and a 400 neither fails over nor
74 /// retries, so "omit it" had to be expressible rather than approximated by a
75 /// low value.
76 pub(crate) temperature: Option<f32>,
77 /// `None` means "no ceiling": since open-agent-sdk 0.7.0 an unset
78 /// `max_tokens` is omitted from the request entirely and the server decides.
79 /// Before 0.7.0 the builder substituted 4096, which truncated reasoning
80 /// models mid-thought, and drep had to pass a large sentinel instead.
81 pub(crate) max_tokens: Option<u32>,
82 pub(crate) timeout_secs: u64,
83 pub(crate) retry_config: RetryConfig,
84}
85
86/// Hand-written so the API key cannot reach a log.
87///
88/// A derived `Debug` prints every field, so any `{:?}`, `dbg!` or tracing line
89/// touching the client would emit a live credential.
90impl std::fmt::Debug for LlmClient {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.debug_struct("LlmClient")
93 .field("base_url", &self.base_url)
94 .field("model", &self.model)
95 .field("api_key", &"<redacted>")
96 .field("protocol", &self.protocol.as_str())
97 .field("temperature", &self.temperature)
98 .field("max_tokens", &self.max_tokens)
99 .field("timeout_secs", &self.timeout_secs)
100 .finish()
101 }
102}
103
104impl LlmClient {
105 /// The model this client asks for.
106 ///
107 /// An accessor rather than a second copy on the caller: the cache key is
108 /// computed from the model, and a struct holding its own `model` string is
109 /// exactly what lets a request go to one model while the key names
110 /// another.
111 pub fn model(&self) -> &str {
112 &self.model
113 }
114
115 /// The base URL this client talks to. For display - `doctor` and the
116 /// failover report name the endpoint a provider used.
117 pub fn endpoint(&self) -> &str {
118 &self.base_url
119 }
120
121 /// The sampling temperature, or `None` when none is sent. Part of the cache
122 /// key, for the same reason [`Self::model`] is - and `None` has to key
123 /// differently from any value, because the answers genuinely differ.
124 pub fn temperature(&self) -> Option<f32> {
125 self.temperature
126 }
127
128 /// The wire protocol this client speaks. For display, and for the cache key:
129 /// the same model at the same endpoint over two protocols is two requests.
130 pub fn protocol(&self) -> ApiProtocol {
131 self.protocol
132 }
133
134 /// Build a client from a validated `LlmConfig`.
135 ///
136 /// Returns [`LlmError::NotConfigured`] when the config does not name an
137 /// endpoint, a model, or has `enabled = false`. We do not default an
138 /// endpoint - "LLM was disabled" and "LLM is enabled but misconfigured"
139 /// are both fatal here, and inventing a value would mask a broken
140 /// install.
141 pub fn new(cfg: &LlmConfig) -> Result<Self, LlmError> {
142 if !cfg.enabled {
143 return Err(LlmError::NotConfigured(
144 "LLM is disabled in config (set `enabled = true`)".to_string(),
145 ));
146 }
147 let endpoint = cfg.endpoint.clone().ok_or_else(|| {
148 LlmError::NotConfigured("LLM endpoint is not set in config".to_string())
149 })?;
150 let model = cfg
151 .model
152 .clone()
153 .ok_or_else(|| LlmError::NotConfigured("LLM model is not set in config".to_string()))?;
154
155 let api_key = cfg
156 .api_key
157 .clone()
158 .unwrap_or_else(|| "not-needed".to_string());
159
160 // `config::load` already rejected an unknown name, so this cannot fail for a
161 // config that came through the loader. It is re-checked rather than unwrapped
162 // because `LlmClient::new` is also reachable from tests that build an
163 // `LlmConfig` directly, and a silent default here would post
164 // chat-completions bytes to a `/messages` endpoint.
165 let protocol = crate::config::parse_protocol(cfg.protocol.as_deref()).ok_or_else(|| {
166 LlmError::NotConfigured(format!(
167 "unknown protocol `{}`; expected `openai` or `anthropic`",
168 cfg.protocol.as_deref().unwrap_or_default()
169 ))
170 })?;
171
172 // max_retries is a total attempt count; the floor is 1 so a config of
173 // 0 still performs exactly one attempt. See the spec's note on the
174 // bogus "no exception was captured" failure.
175 let max_attempts = cfg.max_retries.max(1);
176
177 Ok(LlmClient {
178 base_url: endpoint,
179 model,
180 api_key,
181 protocol,
182 temperature: cfg.temperature,
183 max_tokens: cfg.max_tokens,
184 timeout_secs: cfg.timeout_secs,
185 retry_config: RetryConfig {
186 max_attempts,
187 initial_delay: Duration::from_secs(1),
188 max_delay: Duration::from_secs(60),
189 backoff_multiplier: 2.0,
190 jitter_factor: 0.1,
191 },
192 })
193 }
194
195 /// Send one prompt and return the extracted JSON.
196 ///
197 /// Concatenates `ContentBlock::Text` blocks in arrival order; other block
198 /// variants are ignored. An empty response body is **retried** as a
199 /// transport failure and, if it keeps coming back empty, surfaces as
200 /// [`LlmError::Transport`] - see `run_one_query` for why "the model
201 /// returned nothing" is not the deterministic outcome it looks like.
202 ///
203 /// A non-empty body that yields **no JSON at all** is retried up to
204 /// [`NO_JSON_ATTEMPTS`] times and then becomes [`LlmError::Unparseable`],
205 /// carrying an excerpt of what actually came back. A body that parsed only
206 /// after brace-balancing ([`Extracted::Truncated`]) is returned
207 /// immediately and never retried - that is the genuinely deterministic
208 /// case, and the one the "never retry" rule was written for.
209 pub async fn complete_json(
210 &self,
211 system_prompt: &str,
212 user_content: &str,
213 ) -> Result<Extracted, LlmError> {
214 // Build options per request. The SDK doesn't expose a way to set
215 // `system_prompt` after `build()`, and the retry closure needs to
216 // borrow the same options across attempts.
217 let builder = AgentOptions::builder()
218 .model(&self.model)
219 .base_url(&self.base_url)
220 .api_key(&self.api_key)
221 .system_prompt(system_prompt)
222 .protocol(self.protocol)
223 .timeout(self.timeout_secs);
224
225 // Only send a temperature when one was configured. An unset value means the
226 // field is omitted entirely, which is the only thing that works against a
227 // model that rejects the parameter.
228 let builder = match self.temperature {
229 Some(temperature) => builder.temperature(temperature),
230 None => builder,
231 };
232
233 // Only set a ceiling when the user asked for one. open-agent-sdk 0.7.0
234 // omits `max_tokens` from the request when the setter is never called,
235 // so "unset" genuinely means "let the server decide" rather than the
236 // implicit 4096 earlier versions substituted.
237 let builder = match self.max_tokens {
238 Some(limit) => builder.max_tokens(limit),
239 None => builder,
240 };
241
242 let options = builder
243 .build()
244 .map_err(|e| LlmError::NotConfigured(format!("AgentOptions build failed: {e}")))?;
245
246 let prompt = user_content.to_string();
247
248 // The no-JSON retry is drep's own loop, deliberately *outside* the
249 // SDK's. Handing "no JSON" to the SDK by returning `Err` would work,
250 // but it would surface as `LlmError::Transport` once the attempts ran
251 // out - and `Transport` fails over to the next provider and demotes
252 // this one for the whole run. A model that answered in prose has told
253 // us nothing about the endpoint, so neither of those is right.
254 //
255 // The SDK's own retry still runs inside each pass, so a transport
256 // failure is handled by the layer that classifies it.
257 let mut last_body = String::new();
258 for _ in 0..NO_JSON_ATTEMPTS {
259 let result: open_agent::Result<Answer> =
260 retry_with_backoff_conditional(self.retry_config.clone(), || {
261 self.run_one_query(&prompt, &options)
262 })
263 .await;
264
265 match result {
266 Ok(Answer::Parsed(extracted)) => return Ok(extracted),
267 // The server said why it stopped, and the reason rules out a
268 // retry: the request hit a limit, so the same request hits the
269 // same limit. This is the genuinely deterministic case the
270 // original "never retry a non-empty body" rule was reaching
271 // for - it just used "no JSON in the body" as the proxy, which
272 // is not the same question.
273 Ok(Answer::NoJson { text, finish }) if !worth_asking_again(&finish) => {
274 return Err(LlmError::ModelStopped {
275 finish: finish.as_str().to_owned(),
276 message: stopped_message(&finish, &text),
277 });
278 }
279 Ok(Answer::NoJson { text, .. }) => last_body = text,
280 Err(e) => {
281 // The SDK exposes the status code separately (via
282 // `status_code`); reading it before formatting means the
283 // number survives as a number, and a later caller can
284 // branch on it rather than parsing the message.
285 let status = e.status_code();
286 let message = format!("{e}");
287 return Err(LlmError::Transport { status, message });
288 }
289 }
290 }
291
292 Err(LlmError::Unparseable(format!(
293 "no JSON in the response after {NO_JSON_ATTEMPTS} attempts; \
294 the model answered: {}",
295 excerpt(&last_body, RESPONSE_EXCERPT_MAX)
296 )))
297 }
298
299 /// One attempt: stream the response, concatenate text, parse.
300 ///
301 /// Returns [`Answer::NoJson`] carrying the raw text when the query
302 /// produced something we could not parse at all - the SDK's retry layer
303 /// sees `Ok` and stops, leaving the decision to `complete_json`. Returns
304 /// `Err(SdkError)` for a transport-level failure, including an unexplained
305 /// empty response. An empty response with a terminal `Length` or
306 /// `ContentFilter` reason stays [`Answer::NoJson`], because the reason says
307 /// the same request cannot benefit from a retry.
308 async fn run_one_query(
309 &self,
310 prompt: &str,
311 options: &AgentOptions,
312 ) -> open_agent::Result<Answer> {
313 let mut stream = query(prompt, options).await?;
314 let mut text = String::new();
315 // `Unspecified` is the right default rather than a panic-if-absent:
316 // several OpenAI-compatible servers never report a reason at all, and
317 // "no information" is a distinct answer from "stopped normally".
318 let mut finish = FinishReason::Unspecified;
319 while let Some(event) = stream.next().await {
320 match event? {
321 // Image, ToolUse, ToolResult are not used here.
322 StreamEvent::Block(ContentBlock::Text(t)) => text.push_str(&t.text),
323 StreamEvent::Finish(reason) => finish = reason,
324 // Everything else is discarded, and that is the contract:
325 // `text` holds assistant text and nothing else. It covers the
326 // non-text blocks drep has no use for, the `Reasoning` side
327 // channel (opt-in, and drep does not opt in - chain-of-thought
328 // must never reach the text drep parses as JSON), and any
329 // variant a later SDK adds, since `StreamEvent` is
330 // `#[non_exhaustive]`. Spelled as one arm because a separate
331 // `Reasoning(_) => {}` above it does the same nothing, and an
332 // arm indistinguishable from the wildcard is dead code.
333 _ => {}
334 }
335 }
336
337 // An empty body is a **transport** failure, not a parse failure.
338 //
339 // This distinction was learned the expensive way. Both cases used to
340 // return `Ok(None)` and become a non-retrying `Unparseable`, on the
341 // stated reasoning that "the model returned nothing" repeats
342 // deterministically for the same prompt. It does not: on drep's own
343 // first gated push, 7 of 49 files came back with no parseable JSON,
344 // and re-running one of them immediately afterwards succeeded with
345 // findings and exit 0. The provider had simply returned nothing that
346 // time - which is exactly the `finish_reason='error'` flakiness that
347 // blocked three consecutive pushes under 1.x.
348 //
349 // `Error::stream` is classified retryable by the SDK, which is both
350 // accurate (the stream completed carrying no content) and nearly free:
351 // a response with no output tokens cost nothing to produce, so asking
352 // again is cheap. A *non-empty* body we cannot parse still returns
353 // `Ok(None)` and still does not retry - that is the deterministic case
354 // the split was built for, and re-sending it burns a full reasoning
355 // call for the same answer.
356 if text.trim().is_empty() && worth_asking_again(&finish) {
357 return Err(open_agent::Error::stream(
358 "the model returned an empty response",
359 ));
360 }
361
362 Ok(match extract_json(&text) {
363 Some(extracted) => Answer::Parsed(extracted),
364 // The text is carried out rather than dropped. It was discarded
365 // behind the constant "response contained no parseable JSON",
366 // which made every occurrence of this failure look identical and
367 // left no way to tell a refusal from a prose preamble from
368 // reasoning that leaked into the content channel.
369 None => Answer::NoJson { text, finish },
370 })
371 }
372}
373
374/// What one query produced, before the retry decision.
375///
376/// `NoJson` carries the body so the failure can be diagnosed and so
377/// `complete_json` can decide whether to ask again. The SDK's retry layer
378/// treats both variants as success and stops, which is what keeps the
379/// no-JSON decision here rather than inside it.
380enum Answer {
381 Parsed(Extracted),
382 /// No JSON at all, with why generation stopped. The reason decides whether
383 /// asking again can possibly help.
384 NoJson {
385 text: String,
386 finish: FinishReason,
387 },
388}
389
390/// How many times a response carrying no JSON at all is asked for again.
391///
392/// Not the same question as the SDK's transport retry, and deliberately a
393/// small number: each attempt is a full reasoning call. The rule this replaced
394/// never retried, justified as "the same prompt truncates the same way" - but
395/// that is [`Extracted::Truncated`], a different branch. A response with *no
396/// JSON at all* did not truncate an answer, it never produced one, and in
397/// practice it does not repeat: drep's own gated push failed on a different
398/// file each run, and each failing file analyzed cleanly when asked again.
399///
400/// Two attempts, so one retry. The evidence is that a single retry clears it,
401/// and a model that answers in prose twice is not going to be talked round on
402/// the third try.
403pub const NO_JSON_ATTEMPTS: u32 = 2;
404
405/// Whether asking the same question again could produce a different answer.
406///
407/// `false` for the reasons that are a property of the *request*: a token cap is
408/// hit identically every time, and a content filter that refused this payload
409/// refuses it again. `true` where the server told us nothing useful, because a
410/// model at temperature above zero can simply answer differently - which is
411/// what drep's own gated push demonstrated, failing on a different file each
412/// run with every failing file analyzing cleanly when asked again.
413fn worth_asking_again(finish: &FinishReason) -> bool {
414 // Written as a negated match on the two request-shaped reasons rather than
415 // as an enumeration of the rest. `FinishReason` is `#[non_exhaustive]`, so
416 // a wildcard arm is required either way - and an enumerated "everything
417 // else is retryable" arm sitting above it is behaviourally identical to the
418 // wildcard, which makes it undeletable-but-unobservable: exactly the dead
419 // code the mutation gate exists to find.
420 //
421 // The consequence of the wildcard is deliberate: a reason a later SDK adds
422 // defaults to retrying. The retry is bounded and cheap to be wrong about,
423 // whereas refusing to retry something transient fails a commit outright.
424 !matches!(
425 finish,
426 // A token cap is hit identically every time - drep sends no
427 // `max_tokens`, so the cap is the server's. A content filter that
428 // refused this payload refuses it again.
429 FinishReason::Length | FinishReason::ContentFilter
430 )
431}
432
433/// A sentence a user can act on, for the reasons that end the attempt.
434///
435/// The two cases want different actions - one is "this file is too big for this
436/// model in one pass", the other is "this provider refused the content" - so
437/// they do not share a message.
438fn stopped_message(finish: &FinishReason, text: &str) -> String {
439 match finish {
440 FinishReason::Length => format!(
441 "the model hit its output token limit before producing any JSON. \
442 This file is too large for this model to review in one request - \
443 split it, or use a provider with a larger output budget. \
444 It managed: {}",
445 excerpt(text, RESPONSE_EXCERPT_MAX)
446 ),
447 _ => format!(
448 "the model stopped ({}) before producing any JSON: {}",
449 finish.as_str(),
450 excerpt(text, RESPONSE_EXCERPT_MAX)
451 ),
452 }
453}
454
455#[cfg(test)]
456mod tests;