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};
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    format!("{instructions}\n{RESPONSE_JSON_SCHEMA}")
331}
332
333pub fn build_user_message(config: &LlmConfig, phase: LlmPhase, context: &Value) -> Result<String> {
334    let strategy_context = config.prompts.effective_context(phase);
335    let context_json = serde_json::to_string_pretty(context)?;
336    if strategy_context.trim().is_empty() {
337        Ok(format!(
338            "Review this options agent state and advise. Context JSON:\n{context_json}"
339        ))
340    } else {
341        Ok(format!(
342            "Strategy context:\n{strategy_context}\n\nReview this options agent state and advise. Context JSON:\n{context_json}"
343        ))
344    }
345}
346
347fn parse_llm_review(
348    phase: LlmPhase,
349    model: &str,
350    used_web: bool,
351    parsed: Value,
352) -> Result<LlmReview> {
353    let market_commentary = required_string(&parsed, "market_commentary")?;
354    let entry_recommendation = parsed
355        .pointer("/new_entries/recommendation")
356        .and_then(|v| v.as_str())
357        .map(|s| s.trim().to_ascii_lowercase())
358        .filter(|s| matches!(s.as_str(), "proceed" | "defer" | "skip"))
359        .context("LLM review missing valid new_entries.recommendation")?;
360    let entry_reasoning = parsed
361        .pointer("/new_entries/reasoning")
362        .and_then(|v| v.as_str())
363        .context("LLM review missing new_entries.reasoning")?
364        .to_string();
365
366    let position_reviews = parsed
367        .get("positions")
368        .and_then(|v| v.as_array())
369        .map(|arr| {
370            arr.iter()
371                .filter_map(|p| {
372                    Some(PositionReview {
373                        position_id: p.get("position_id")?.as_str()?.to_string(),
374                        recommendation: p
375                            .get("recommendation")
376                            .and_then(|v| v.as_str())
377                            .unwrap_or("hold")
378                            .to_string(),
379                        urgency: p
380                            .get("urgency")
381                            .and_then(|v| v.as_str())
382                            .unwrap_or("low")
383                            .to_string(),
384                        reasoning: p
385                            .get("reasoning")
386                            .and_then(|v| v.as_str())
387                            .unwrap_or("")
388                            .to_string(),
389                    })
390                })
391                .collect()
392        })
393        .unwrap_or_default();
394
395    Ok(LlmReview {
396        phase: phase_label(phase).to_string(),
397        model: model.to_string(),
398        used_web,
399        market_commentary,
400        web_insights: parsed
401            .get("web_insights")
402            .and_then(|v| v.as_array())
403            .map(|a| {
404                a.iter()
405                    .filter_map(|v| v.as_str().map(str::to_string))
406                    .collect()
407            })
408            .unwrap_or_default(),
409        entry_recommendation,
410        entry_reasoning,
411        risk_alerts: parsed
412            .get("risk_alerts")
413            .and_then(|v| v.as_array())
414            .map(|a| {
415                a.iter()
416                    .filter_map(|v| v.as_str().map(str::to_string))
417                    .collect()
418            })
419            .unwrap_or_default(),
420        position_reviews,
421        raw: parsed,
422    })
423}
424
425fn required_string(parsed: &Value, key: &str) -> Result<String> {
426    parsed
427        .get(key)
428        .and_then(|v| v.as_str())
429        .map(str::to_string)
430        .with_context(|| format!("LLM review missing {key}"))
431}
432
433fn phase_label(phase: LlmPhase) -> &'static str {
434    match phase {
435        LlmPhase::Selection => "selection",
436        LlmPhase::Monitor => "monitor",
437        LlmPhase::OvernightDigest => "overnight_digest",
438    }
439}
440
441impl LlmReview {
442    pub fn to_json(&self) -> Value {
443        json!({
444            "phase": self.phase,
445            "model": self.model,
446            "used_web": self.used_web,
447            "market_commentary": self.market_commentary,
448            "web_insights": self.web_insights,
449            "positions": self.position_reviews,
450            "new_entries": {
451                "recommendation": self.entry_recommendation,
452                "reasoning": self.entry_reasoning,
453            },
454            "risk_alerts": self.risk_alerts,
455        })
456    }
457
458    pub fn should_veto_entries(&self) -> bool {
459        matches!(
460            self.entry_recommendation.as_str(),
461            "skip" | "defer" | "hold"
462        )
463    }
464
465    pub fn urgent_close_positions(&self) -> Vec<&PositionReview> {
466        self.position_reviews
467            .iter()
468            .filter(|p| {
469                p.recommendation.eq_ignore_ascii_case("close")
470                    && p.urgency.eq_ignore_ascii_case("high")
471            })
472            .collect()
473    }
474}
475
476/// Extract JSON object from markdown fences or leading prose (web models).
477pub fn extract_json_object(content: &str) -> Result<Value> {
478    let trimmed = content.trim();
479    if let Ok(v) = serde_json::from_str(trimmed) {
480        return Ok(v);
481    }
482
483    for fence in ["```json", "```JSON", "```"] {
484        if let Some(start) = trimmed.find(fence) {
485            let after = &trimmed[start + fence.len()..];
486            if let Some(end) = after.find("```") {
487                let block = after[..end].trim();
488                if let Ok(v) = serde_json::from_str(block) {
489                    return Ok(v);
490                }
491                if let Ok(v) = extract_json_object(block) {
492                    return Ok(v);
493                }
494            }
495        }
496    }
497
498    if let Some(start) = trimmed.find('{') {
499        if let Some(end) = trimmed.rfind('}') {
500            if end > start {
501                return Ok(serde_json::from_str(&trimmed[start..=end])?);
502            }
503        }
504    }
505    anyhow::bail!("no JSON object found in LLM response")
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use crate::rules::LlmPromptsConfig;
512
513    #[test]
514    fn parses_llm_json() {
515        let raw = json!({
516            "market_commentary": "Markets calm",
517            "web_insights": ["VIX low"],
518            "positions": [{
519                "position_id": "IWM|2026-07-24",
520                "recommendation": "hold",
521                "urgency": "low",
522                "reasoning": "On track"
523            }],
524            "new_entries": { "recommendation": "proceed", "reasoning": "ok" },
525            "risk_alerts": []
526        });
527        let review = parse_llm_review(LlmPhase::Selection, "test", true, raw).unwrap();
528        assert_eq!(review.phase, "selection");
529        assert_eq!(review.entry_recommendation, "proceed");
530        assert_eq!(review.position_reviews.len(), 1);
531    }
532
533    #[test]
534    fn system_prompt_uses_configured_selection_instructions() {
535        let mut config = LlmConfig::default();
536        config.prompts.selection = "YOLO aggressive trader.".into();
537        let prompt = build_system_prompt(&config, LlmPhase::Selection, false);
538        assert!(prompt.contains("YOLO aggressive trader."));
539        assert!(prompt.contains("valid JSON"));
540    }
541
542    #[test]
543    fn user_message_includes_strategy_context() {
544        let mut config = LlmConfig::default();
545        config.prompts.selection_context = "Account 9947: conservative income pilot.".into();
546        let msg = build_user_message(&config, LlmPhase::Selection, &json!({"tick": 1})).unwrap();
547        assert!(msg.contains("Account 9947"));
548        assert!(msg.contains("\"tick\": 1"));
549    }
550
551    #[test]
552    fn empty_context_omits_strategy_block() {
553        let config = LlmConfig {
554            prompts: LlmPromptsConfig {
555                selection_context: String::new(),
556                ..Default::default()
557            },
558            ..Default::default()
559        };
560        let msg = build_user_message(&config, LlmPhase::Selection, &json!({})).unwrap();
561        assert!(!msg.contains("Strategy context:"));
562    }
563
564    #[test]
565    fn extracts_json_from_markdown_fence() {
566        let raw = r#"Here is the review:
567```json
568{"market_commentary":"ok","web_insights":[],"positions":[],"new_entries":{"recommendation":"proceed","reasoning":"fine"},"risk_alerts":[]}
569```"#;
570        let parsed = parse_llm_json_content(raw).unwrap();
571        assert_eq!(
572            parsed
573                .pointer("/new_entries/recommendation")
574                .and_then(|v| v.as_str()),
575            Some("proceed")
576        );
577    }
578
579    #[test]
580    fn llm_review_schema_has_required_fields() {
581        let schema = llm_review_json_schema();
582        let required = schema
583            .get("required")
584            .and_then(|v| v.as_array())
585            .expect("required array");
586        assert!(required.iter().any(|v| v.as_str() == Some("positions")));
587        assert!(required.iter().any(|v| v.as_str() == Some("new_entries")));
588    }
589
590    #[test]
591    fn missing_entry_recommendation_is_rejected() {
592        let raw = json!({
593            "market_commentary": "",
594            "web_insights": [],
595            "positions": [],
596            "new_entries": { "reasoning": "" },
597            "risk_alerts": []
598        });
599        let err = parse_llm_review(LlmPhase::Selection, "test", true, raw).unwrap_err();
600        assert!(err.to_string().contains("new_entries.recommendation"));
601    }
602
603    #[test]
604    fn empty_json_is_not_a_proceed_review() {
605        let err = parse_llm_review(LlmPhase::Selection, "test", true, json!({})).unwrap_err();
606        assert!(err.to_string().contains("market_commentary"));
607    }
608}