Skip to main content

schwab_cli/agent/
llm.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use serde_json::{json, Value};
4
5use crate::rules::{LlmConfig, LlmPhase, selection_market_context_guardrails};
6
7const OPENROUTER_URL: &str = "https://openrouter.ai/api/v1/chat/completions";
8
9const RESPONSE_JSON_SCHEMA: &str = r#"
10Respond ONLY with valid JSON matching this schema:
11{
12  "market_commentary": "string",
13  "web_insights": ["string"],
14  "positions": [{"position_id": "underlying|expiry", "recommendation": "hold|close|watch", "urgency": "low|medium|high", "reasoning": "string"}],
15  "new_entries": {"recommendation": "proceed|defer|skip", "reasoning": "string"},
16  "risk_alerts": ["string"]
17}"#;
18
19#[derive(Debug, Clone, Copy)]
20enum LlmResponseFormat {
21    JsonSchema,
22    JsonObject,
23    Plain,
24}
25
26fn llm_review_json_schema() -> Value {
27    json!({
28        "type": "object",
29        "properties": {
30            "market_commentary": {
31                "type": "string",
32                "description": "Brief market / portfolio commentary"
33            },
34            "web_insights": {
35                "type": "array",
36                "items": { "type": "string" },
37                "description": "Optional web research bullets (empty array if none)"
38            },
39            "positions": {
40                "type": "array",
41                "items": {
42                    "type": "object",
43                    "properties": {
44                        "position_id": {
45                            "type": "string",
46                            "description": "Position id, e.g. IWM|2026-07-31"
47                        },
48                        "recommendation": {
49                            "type": "string",
50                            "description": "hold (comfortably OTM), watch (elevated delta/near strike), or close (thesis break only)"
51                        },
52                        "urgency": {
53                            "type": "string",
54                            "description": "low, medium, or high (high only for imminent assignment/gap through short strike)"
55                        },
56                        "reasoning": {
57                            "type": "string",
58                            "description": "Cite market_context: short_delta, short_otm_pct, distance_to_short_strike_usd"
59                        }
60                    },
61                    "required": ["position_id", "recommendation", "urgency", "reasoning"],
62                    "additionalProperties": false
63                }
64            },
65            "new_entries": {
66                "type": "object",
67                "properties": {
68                    "recommendation": {
69                        "type": "string",
70                        "description": "proceed, defer, or skip"
71                    },
72                    "reasoning": { "type": "string" }
73                },
74                "required": ["recommendation", "reasoning"],
75                "additionalProperties": false
76            },
77            "risk_alerts": {
78                "type": "array",
79                "items": { "type": "string" }
80            }
81        },
82        "required": [
83            "market_commentary",
84            "web_insights",
85            "positions",
86            "new_entries",
87            "risk_alerts"
88        ],
89        "additionalProperties": false
90    })
91}
92
93fn response_format_for_mode(mode: LlmResponseFormat) -> Option<Value> {
94    match mode {
95        LlmResponseFormat::JsonSchema => Some(json!({
96            "type": "json_schema",
97            "json_schema": {
98                "name": "agent_review",
99                "strict": true,
100                "schema": llm_review_json_schema()
101            }
102        })),
103        LlmResponseFormat::JsonObject => Some(json!({ "type": "json_object" })),
104        LlmResponseFormat::Plain => None,
105    }
106}
107
108fn plugins_for_mode(mode: LlmResponseFormat) -> Option<Value> {
109    match mode {
110        LlmResponseFormat::JsonSchema | LlmResponseFormat::JsonObject => {
111            Some(json!([{ "id": "response-healing" }]))
112        }
113        LlmResponseFormat::Plain => None,
114    }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct LlmReview {
119    pub phase: String,
120    pub model: String,
121    pub used_web: bool,
122    pub raw: Value,
123    pub market_commentary: String,
124    pub web_insights: Vec<String>,
125    pub position_reviews: Vec<PositionReview>,
126    pub entry_recommendation: String,
127    pub entry_reasoning: String,
128    pub risk_alerts: Vec<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct PositionReview {
133    pub position_id: String,
134    pub recommendation: String,
135    pub urgency: String,
136    pub reasoning: String,
137}
138
139pub struct OpenRouterClient {
140    http: reqwest::Client,
141    api_key: String,
142}
143
144impl OpenRouterClient {
145    pub fn from_env() -> Result<Self> {
146        let api_key = std::env::var("OPENROUTER_API_KEY")
147            .context("OPENROUTER_API_KEY is required when llm.enabled is true")?;
148        Ok(Self {
149            http: reqwest::Client::new(),
150            api_key,
151        })
152    }
153
154    pub async fn review(
155        &self,
156        config: &LlmConfig,
157        phase: LlmPhase,
158        context: &Value,
159        use_web: bool,
160    ) -> Result<LlmReview> {
161        let model = config.resolve_model(phase, use_web);
162        let system = build_system_prompt(config, phase, use_web);
163        let user = build_user_message(config, phase, context)?;
164
165        let modes = if use_web {
166            vec![LlmResponseFormat::Plain]
167        } else {
168            vec![LlmResponseFormat::JsonSchema, LlmResponseFormat::JsonObject]
169        };
170
171        let mut last_err = None;
172        for mode in modes {
173            match self
174                .review_with_format(model, &system, &user, config.max_tokens, mode)
175                .await
176            {
177                Ok(review) => return parse_llm_review(phase, model, use_web, review),
178                Err(err) if use_web || !is_retryable_format_error(&err) => return Err(err),
179                Err(err) => last_err = Some(err),
180            }
181        }
182
183        Err(last_err.unwrap_or_else(|| anyhow::anyhow!("LLM review failed")))
184    }
185
186    async fn review_with_format(
187        &self,
188        model: &str,
189        system: &str,
190        user: &str,
191        max_tokens: u32,
192        mode: LlmResponseFormat,
193    ) -> Result<Value> {
194        let mut body = json!({
195            "model": model,
196            "messages": [
197                { "role": "system", "content": system },
198                { "role": "user", "content": user }
199            ],
200            "max_tokens": max_tokens,
201        });
202
203        if let Some(response_format) = response_format_for_mode(mode) {
204            body["response_format"] = response_format;
205        }
206        if let Some(plugins) = plugins_for_mode(mode) {
207            body["plugins"] = plugins;
208        }
209
210        let resp = self
211            .http
212            .post(OPENROUTER_URL)
213            .header("Authorization", format!("Bearer {}", self.api_key))
214            .header("Content-Type", "application/json")
215            .header("HTTP-Referer", "https://github.com/schwabinvestbot")
216            .header("X-Title", "schwabinvestbot options agent")
217            .json(&body)
218            .send()
219            .await
220            .context("OpenRouter request failed")?;
221
222        let status = resp.status();
223        let payload: Value = resp
224            .json()
225            .await
226            .context("OpenRouter response parse failed")?;
227        if !status.is_success() {
228            let fallback = payload.to_string();
229            let message = payload
230                .pointer("/error/message")
231                .and_then(|v| v.as_str())
232                .unwrap_or(&fallback);
233            if status.as_u16() == 401 {
234                anyhow::bail!(
235                    "OpenRouter error {status}: {message}. \
236                     Check OPENROUTER_API_KEY in the project .env (it overrides shell exports). \
237                     Create or rotate a key at https://openrouter.ai/keys"
238                );
239            }
240            anyhow::bail!("OpenRouter error {status}: {message}");
241        }
242
243        let content = extract_message_content(&payload)?;
244        parse_llm_json_content(&content)
245    }
246}
247
248fn is_retryable_format_error(err: &anyhow::Error) -> bool {
249    let msg = err.to_string().to_ascii_lowercase();
250    msg.contains("json_schema")
251        || msg.contains("structured")
252        || msg.contains("response_format")
253        || msg.contains("not support")
254        || msg.contains("unsupported")
255        || msg.contains("invalid parameter")
256}
257
258fn extract_message_content(payload: &Value) -> Result<String> {
259    let message = payload
260        .pointer("/choices/0/message")
261        .context("OpenRouter response missing message")?;
262
263    if let Some(text) = message_content_as_str(message.get("content")) {
264        if !text.trim().is_empty() {
265            return Ok(text);
266        }
267    }
268
269    if let Some(reasoning) = message_content_as_str(message.get("reasoning")) {
270        if !reasoning.trim().is_empty() {
271            return Ok(reasoning);
272        }
273    }
274
275    if let Some(refusal) = message.get("refusal").and_then(|v| v.as_str()) {
276        if !refusal.trim().is_empty() {
277            anyhow::bail!("LLM refused request: {refusal}");
278        }
279    }
280
281    anyhow::bail!(
282        "OpenRouter response missing usable content (model may not support structured output for this route)"
283    )
284}
285
286fn message_content_as_str(content: Option<&Value>) -> Option<String> {
287    let content = content?;
288    if let Some(text) = content.as_str() {
289        return Some(text.to_string());
290    }
291    if let Some(parts) = content.as_array() {
292        let mut out = String::new();
293        for part in parts {
294            if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
295                out.push_str(text);
296            }
297        }
298        if out.is_empty() {
299            None
300        } else {
301            Some(out)
302        }
303    } else {
304        None
305    }
306}
307
308fn parse_llm_json_content(content: &str) -> Result<Value> {
309    serde_json::from_str(content.trim())
310        .or_else(|_| extract_json_object(content))
311        .with_context(|| format_llm_parse_error(content))
312}
313
314fn format_llm_parse_error(content: &str) -> String {
315    let preview: String = content.chars().take(240).collect();
316    let suffix = if content.chars().count() > 240 {
317        "…"
318    } else {
319        ""
320    };
321    format!("LLM returned non-JSON content: {preview}{suffix}")
322}
323
324pub fn build_system_prompt(config: &LlmConfig, phase: LlmPhase, use_web: bool) -> String {
325    let instructions = match phase {
326        LlmPhase::Selection => config.prompts.effective_selection_instructions(use_web),
327        LlmPhase::Monitor => config.prompts.effective_monitor_instructions(),
328        LlmPhase::OvernightDigest => config.prompts.effective_overnight_instructions(),
329    };
330    let mut prompt = format!("{instructions}\n{RESPONSE_JSON_SCHEMA}");
331    if matches!(phase, LlmPhase::Selection) {
332        prompt.push_str("\n\n");
333        prompt.push_str(selection_market_context_guardrails());
334    }
335    prompt
336}
337
338pub fn build_user_message(config: &LlmConfig, phase: LlmPhase, context: &Value) -> Result<String> {
339    let strategy_context = config.prompts.effective_context(phase);
340    let context_json = serde_json::to_string_pretty(context)?;
341    if strategy_context.trim().is_empty() {
342        Ok(format!(
343            "Review this options agent state and advise. Context JSON:\n{context_json}"
344        ))
345    } else {
346        Ok(format!(
347            "Strategy context:\n{strategy_context}\n\nReview this options agent state and advise. Context JSON:\n{context_json}"
348        ))
349    }
350}
351
352fn parse_llm_review(
353    phase: LlmPhase,
354    model: &str,
355    used_web: bool,
356    parsed: Value,
357) -> Result<LlmReview> {
358    let market_commentary = required_string(&parsed, "market_commentary")?;
359    let entry_recommendation = parsed
360        .pointer("/new_entries/recommendation")
361        .and_then(|v| v.as_str())
362        .map(|s| s.trim().to_ascii_lowercase())
363        .filter(|s| matches!(s.as_str(), "proceed" | "defer" | "skip"))
364        .context("LLM review missing valid new_entries.recommendation")?;
365    let entry_reasoning = parsed
366        .pointer("/new_entries/reasoning")
367        .and_then(|v| v.as_str())
368        .context("LLM review missing new_entries.reasoning")?
369        .to_string();
370
371    let position_reviews = parsed
372        .get("positions")
373        .and_then(|v| v.as_array())
374        .map(|arr| {
375            arr.iter()
376                .filter_map(|p| {
377                    Some(PositionReview {
378                        position_id: p.get("position_id")?.as_str()?.to_string(),
379                        recommendation: p
380                            .get("recommendation")
381                            .and_then(|v| v.as_str())
382                            .unwrap_or("hold")
383                            .to_string(),
384                        urgency: p
385                            .get("urgency")
386                            .and_then(|v| v.as_str())
387                            .unwrap_or("low")
388                            .to_string(),
389                        reasoning: p
390                            .get("reasoning")
391                            .and_then(|v| v.as_str())
392                            .unwrap_or("")
393                            .to_string(),
394                    })
395                })
396                .collect()
397        })
398        .unwrap_or_default();
399
400    Ok(LlmReview {
401        phase: phase_label(phase).to_string(),
402        model: model.to_string(),
403        used_web,
404        market_commentary,
405        web_insights: parsed
406            .get("web_insights")
407            .and_then(|v| v.as_array())
408            .map(|a| {
409                a.iter()
410                    .filter_map(|v| v.as_str().map(str::to_string))
411                    .collect()
412            })
413            .unwrap_or_default(),
414        entry_recommendation,
415        entry_reasoning,
416        risk_alerts: parsed
417            .get("risk_alerts")
418            .and_then(|v| v.as_array())
419            .map(|a| {
420                a.iter()
421                    .filter_map(|v| v.as_str().map(str::to_string))
422                    .collect()
423            })
424            .unwrap_or_default(),
425        position_reviews,
426        raw: parsed,
427    })
428}
429
430fn required_string(parsed: &Value, key: &str) -> Result<String> {
431    parsed
432        .get(key)
433        .and_then(|v| v.as_str())
434        .map(str::to_string)
435        .with_context(|| format!("LLM review missing {key}"))
436}
437
438fn phase_label(phase: LlmPhase) -> &'static str {
439    match phase {
440        LlmPhase::Selection => "selection",
441        LlmPhase::Monitor => "monitor",
442        LlmPhase::OvernightDigest => "overnight_digest",
443    }
444}
445
446impl LlmReview {
447    pub fn to_json(&self) -> Value {
448        json!({
449            "phase": self.phase,
450            "model": self.model,
451            "used_web": self.used_web,
452            "market_commentary": self.market_commentary,
453            "web_insights": self.web_insights,
454            "positions": self.position_reviews,
455            "new_entries": {
456                "recommendation": self.entry_recommendation,
457                "reasoning": self.entry_reasoning,
458            },
459            "risk_alerts": self.risk_alerts,
460        })
461    }
462
463    pub fn should_veto_entries(&self) -> bool {
464        matches!(
465            self.entry_recommendation.as_str(),
466            "skip" | "defer" | "hold"
467        )
468    }
469
470    pub fn urgent_close_positions(&self) -> Vec<&PositionReview> {
471        self.position_reviews
472            .iter()
473            .filter(|p| {
474                p.recommendation.eq_ignore_ascii_case("close")
475                    && p.urgency.eq_ignore_ascii_case("high")
476            })
477            .collect()
478    }
479}
480
481/// Extract JSON object from markdown fences or leading prose (web models).
482pub fn extract_json_object(content: &str) -> Result<Value> {
483    let trimmed = content.trim();
484    if let Ok(v) = serde_json::from_str(trimmed) {
485        return Ok(v);
486    }
487
488    for fence in ["```json", "```JSON", "```"] {
489        if let Some(start) = trimmed.find(fence) {
490            let after = &trimmed[start + fence.len()..];
491            if let Some(end) = after.find("```") {
492                let block = after[..end].trim();
493                if let Ok(v) = serde_json::from_str(block) {
494                    return Ok(v);
495                }
496                if let Ok(v) = extract_json_object(block) {
497                    return Ok(v);
498                }
499            }
500        }
501    }
502
503    if let Some(start) = trimmed.find('{') {
504        if let Some(end) = trimmed.rfind('}') {
505            if end > start {
506                return Ok(serde_json::from_str(&trimmed[start..=end])?);
507            }
508        }
509    }
510    anyhow::bail!("no JSON object found in LLM response")
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::rules::LlmPromptsConfig;
517
518    #[test]
519    fn parses_llm_json() {
520        let raw = json!({
521            "market_commentary": "Markets calm",
522            "web_insights": ["VIX low"],
523            "positions": [{
524                "position_id": "IWM|2026-07-24",
525                "recommendation": "hold",
526                "urgency": "low",
527                "reasoning": "On track"
528            }],
529            "new_entries": { "recommendation": "proceed", "reasoning": "ok" },
530            "risk_alerts": []
531        });
532        let review = parse_llm_review(LlmPhase::Selection, "test", true, raw).unwrap();
533        assert_eq!(review.phase, "selection");
534        assert_eq!(review.entry_recommendation, "proceed");
535        assert_eq!(review.position_reviews.len(), 1);
536    }
537
538    #[test]
539    fn system_prompt_uses_configured_selection_instructions() {
540        let mut config = LlmConfig::default();
541        config.prompts.selection = "YOLO aggressive trader.".into();
542        let prompt = build_system_prompt(&config, LlmPhase::Selection, false);
543        assert!(prompt.contains("YOLO aggressive trader."));
544        assert!(prompt.contains("valid JSON"));
545    }
546
547    #[test]
548    fn user_message_includes_strategy_context() {
549        let mut config = LlmConfig::default();
550        config.prompts.selection_context = "Account 9947: conservative income pilot.".into();
551        let msg = build_user_message(&config, LlmPhase::Selection, &json!({"tick": 1})).unwrap();
552        assert!(msg.contains("Account 9947"));
553        assert!(msg.contains("\"tick\": 1"));
554    }
555
556    #[test]
557    fn empty_context_omits_strategy_block() {
558        let config = LlmConfig {
559            prompts: LlmPromptsConfig {
560                selection_context: String::new(),
561                ..Default::default()
562            },
563            ..Default::default()
564        };
565        let msg = build_user_message(&config, LlmPhase::Selection, &json!({})).unwrap();
566        assert!(!msg.contains("Strategy context:"));
567    }
568
569    #[test]
570    fn extracts_json_from_markdown_fence() {
571        let raw = r#"Here is the review:
572```json
573{"market_commentary":"ok","web_insights":[],"positions":[],"new_entries":{"recommendation":"proceed","reasoning":"fine"},"risk_alerts":[]}
574```"#;
575        let parsed = parse_llm_json_content(raw).unwrap();
576        assert_eq!(
577            parsed
578                .pointer("/new_entries/recommendation")
579                .and_then(|v| v.as_str()),
580            Some("proceed")
581        );
582    }
583
584    #[test]
585    fn llm_review_schema_has_required_fields() {
586        let schema = llm_review_json_schema();
587        let required = schema
588            .get("required")
589            .and_then(|v| v.as_array())
590            .expect("required array");
591        assert!(required.iter().any(|v| v.as_str() == Some("positions")));
592        assert!(required.iter().any(|v| v.as_str() == Some("new_entries")));
593    }
594
595    #[test]
596    fn missing_entry_recommendation_is_rejected() {
597        let raw = json!({
598            "market_commentary": "",
599            "web_insights": [],
600            "positions": [],
601            "new_entries": { "reasoning": "" },
602            "risk_alerts": []
603        });
604        let err = parse_llm_review(LlmPhase::Selection, "test", true, raw).unwrap_err();
605        assert!(err.to_string().contains("new_entries.recommendation"));
606    }
607
608    #[test]
609    fn selection_system_prompt_includes_chain_guardrails() {
610        let config = LlmConfig::default();
611        let prompt = build_system_prompt(&config, LlmPhase::Selection, false);
612        assert!(prompt.contains("CHAIN DATA GUARDRAILS"));
613        assert!(prompt.contains("FORBIDDEN"));
614    }
615
616    #[test]
617    fn monitor_system_prompt_omits_chain_guardrails() {
618        let config = LlmConfig::default();
619        let prompt = build_system_prompt(&config, LlmPhase::Monitor, false);
620        assert!(!prompt.contains("CHAIN DATA GUARDRAILS"));
621    }
622
623    #[test]
624    fn empty_json_is_not_a_proceed_review() {
625        let err = parse_llm_review(LlmPhase::Selection, "test", true, json!({})).unwrap_err();
626        assert!(err.to_string().contains("market_commentary"));
627    }
628}