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 Ok(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.json().await.context("OpenRouter response parse failed")?;
224        if !status.is_success() {
225            let fallback = payload.to_string();
226            let message = payload
227                .pointer("/error/message")
228                .and_then(|v| v.as_str())
229                .unwrap_or(&fallback);
230            if status.as_u16() == 401 {
231                anyhow::bail!(
232                    "OpenRouter error {status}: {message}. \
233                     Check OPENROUTER_API_KEY in the project .env (it overrides shell exports). \
234                     Create or rotate a key at https://openrouter.ai/keys"
235                );
236            }
237            anyhow::bail!("OpenRouter error {status}: {message}");
238        }
239
240        let content = extract_message_content(&payload)?;
241        parse_llm_json_content(&content)
242    }
243}
244
245fn is_retryable_format_error(err: &anyhow::Error) -> bool {
246    let msg = err.to_string().to_ascii_lowercase();
247    msg.contains("json_schema")
248        || msg.contains("structured")
249        || msg.contains("response_format")
250        || msg.contains("not support")
251        || msg.contains("unsupported")
252        || msg.contains("invalid parameter")
253}
254
255fn extract_message_content(payload: &Value) -> Result<String> {
256    let message = payload
257        .pointer("/choices/0/message")
258        .context("OpenRouter response missing message")?;
259
260    if let Some(text) = message_content_as_str(message.get("content")) {
261        if !text.trim().is_empty() {
262            return Ok(text);
263        }
264    }
265
266    if let Some(reasoning) = message_content_as_str(message.get("reasoning")) {
267        if !reasoning.trim().is_empty() {
268            return Ok(reasoning);
269        }
270    }
271
272    if let Some(refusal) = message.get("refusal").and_then(|v| v.as_str()) {
273        if !refusal.trim().is_empty() {
274            anyhow::bail!("LLM refused request: {refusal}");
275        }
276    }
277
278    anyhow::bail!(
279        "OpenRouter response missing usable content (model may not support structured output for this route)"
280    )
281}
282
283fn message_content_as_str(content: Option<&Value>) -> Option<String> {
284    let content = content?;
285    if let Some(text) = content.as_str() {
286        return Some(text.to_string());
287    }
288    if let Some(parts) = content.as_array() {
289        let mut out = String::new();
290        for part in parts {
291            if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
292                out.push_str(text);
293            }
294        }
295        if out.is_empty() {
296            None
297        } else {
298            Some(out)
299        }
300    } else {
301        None
302    }
303}
304
305fn parse_llm_json_content(content: &str) -> Result<Value> {
306    serde_json::from_str(content.trim())
307        .or_else(|_| extract_json_object(content))
308        .with_context(|| format_llm_parse_error(content))
309}
310
311fn format_llm_parse_error(content: &str) -> String {
312    let preview: String = content.chars().take(240).collect();
313    let suffix = if content.chars().count() > 240 { "…" } else { "" };
314    format!("LLM returned non-JSON content: {preview}{suffix}")
315}
316
317pub fn build_system_prompt(config: &LlmConfig, phase: LlmPhase, use_web: bool) -> String {
318    let instructions = match phase {
319        LlmPhase::Selection => config.prompts.effective_selection_instructions(use_web),
320        LlmPhase::Monitor => config.prompts.effective_monitor_instructions(),
321        LlmPhase::OvernightDigest => config.prompts.effective_overnight_instructions(),
322    };
323    format!("{instructions}\n{RESPONSE_JSON_SCHEMA}")
324}
325
326pub fn build_user_message(config: &LlmConfig, phase: LlmPhase, context: &Value) -> Result<String> {
327    let strategy_context = config.prompts.effective_context(phase);
328    let context_json = serde_json::to_string_pretty(context)?;
329    if strategy_context.trim().is_empty() {
330        Ok(format!(
331            "Review this options agent state and advise. Context JSON:\n{context_json}"
332        ))
333    } else {
334        Ok(format!(
335            "Strategy context:\n{strategy_context}\n\nReview this options agent state and advise. Context JSON:\n{context_json}"
336        ))
337    }
338}
339
340fn parse_llm_review(phase: LlmPhase, model: &str, used_web: bool, parsed: Value) -> LlmReview {
341    let position_reviews = parsed
342        .get("positions")
343        .and_then(|v| v.as_array())
344        .map(|arr| {
345            arr.iter()
346                .filter_map(|p| {
347                    Some(PositionReview {
348                        position_id: p.get("position_id")?.as_str()?.to_string(),
349                        recommendation: p
350                            .get("recommendation")
351                            .and_then(|v| v.as_str())
352                            .unwrap_or("hold")
353                            .to_string(),
354                        urgency: p
355                            .get("urgency")
356                            .and_then(|v| v.as_str())
357                            .unwrap_or("low")
358                            .to_string(),
359                        reasoning: p
360                            .get("reasoning")
361                            .and_then(|v| v.as_str())
362                            .unwrap_or("")
363                            .to_string(),
364                    })
365                })
366                .collect()
367        })
368        .unwrap_or_default();
369
370    LlmReview {
371        phase: phase_label(phase).to_string(),
372        model: model.to_string(),
373        used_web,
374        market_commentary: parsed
375            .get("market_commentary")
376            .and_then(|v| v.as_str())
377            .unwrap_or("")
378            .to_string(),
379        web_insights: parsed
380            .get("web_insights")
381            .and_then(|v| v.as_array())
382            .map(|a| {
383                a.iter()
384                    .filter_map(|v| v.as_str().map(str::to_string))
385                    .collect()
386            })
387            .unwrap_or_default(),
388        entry_recommendation: parsed
389            .pointer("/new_entries/recommendation")
390            .and_then(|v| v.as_str())
391            .unwrap_or("proceed")
392            .to_string(),
393        entry_reasoning: parsed
394            .pointer("/new_entries/reasoning")
395            .and_then(|v| v.as_str())
396            .unwrap_or("")
397            .to_string(),
398        risk_alerts: parsed
399            .get("risk_alerts")
400            .and_then(|v| v.as_array())
401            .map(|a| {
402                a.iter()
403                    .filter_map(|v| v.as_str().map(str::to_string))
404                    .collect()
405            })
406            .unwrap_or_default(),
407        position_reviews,
408        raw: parsed,
409    }
410}
411
412fn phase_label(phase: LlmPhase) -> &'static str {
413    match phase {
414        LlmPhase::Selection => "selection",
415        LlmPhase::Monitor => "monitor",
416        LlmPhase::OvernightDigest => "overnight_digest",
417    }
418}
419
420impl LlmReview {
421    pub fn to_json(&self) -> Value {
422        json!({
423            "phase": self.phase,
424            "model": self.model,
425            "used_web": self.used_web,
426            "market_commentary": self.market_commentary,
427            "web_insights": self.web_insights,
428            "positions": self.position_reviews,
429            "new_entries": {
430                "recommendation": self.entry_recommendation,
431                "reasoning": self.entry_reasoning,
432            },
433            "risk_alerts": self.risk_alerts,
434        })
435    }
436
437    pub fn should_veto_entries(&self) -> bool {
438        matches!(
439            self.entry_recommendation.as_str(),
440            "skip" | "defer" | "hold"
441        )
442    }
443
444    pub fn urgent_close_positions(&self) -> Vec<&PositionReview> {
445        self.position_reviews
446            .iter()
447            .filter(|p| {
448                p.recommendation.eq_ignore_ascii_case("close")
449                    && p.urgency.eq_ignore_ascii_case("high")
450            })
451            .collect()
452    }
453}
454
455/// Extract JSON object from markdown fences or leading prose (web models).
456pub fn extract_json_object(content: &str) -> Result<Value> {
457    let trimmed = content.trim();
458    if let Ok(v) = serde_json::from_str(trimmed) {
459        return Ok(v);
460    }
461
462    for fence in ["```json", "```JSON", "```"] {
463        if let Some(start) = trimmed.find(fence) {
464            let after = &trimmed[start + fence.len()..];
465            if let Some(end) = after.find("```") {
466                let block = after[..end].trim();
467                if let Ok(v) = serde_json::from_str(block) {
468                    return Ok(v);
469                }
470                if let Ok(v) = extract_json_object(block) {
471                    return Ok(v);
472                }
473            }
474        }
475    }
476
477    if let Some(start) = trimmed.find('{') {
478        if let Some(end) = trimmed.rfind('}') {
479            if end > start {
480                return Ok(serde_json::from_str(&trimmed[start..=end])?);
481            }
482        }
483    }
484    anyhow::bail!("no JSON object found in LLM response")
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::rules::LlmPromptsConfig;
491
492    #[test]
493    fn parses_llm_json() {
494        let raw = json!({
495            "market_commentary": "Markets calm",
496            "web_insights": ["VIX low"],
497            "positions": [{
498                "position_id": "IWM|2026-07-24",
499                "recommendation": "hold",
500                "urgency": "low",
501                "reasoning": "On track"
502            }],
503            "new_entries": { "recommendation": "proceed", "reasoning": "ok" },
504            "risk_alerts": []
505        });
506        let review = parse_llm_review(LlmPhase::Selection, "test", true, raw);
507        assert_eq!(review.phase, "selection");
508        assert_eq!(review.entry_recommendation, "proceed");
509        assert_eq!(review.position_reviews.len(), 1);
510    }
511
512    #[test]
513    fn system_prompt_uses_configured_selection_instructions() {
514        let mut config = LlmConfig::default();
515        config.prompts.selection = "YOLO aggressive trader.".into();
516        let prompt = build_system_prompt(&config, LlmPhase::Selection, false);
517        assert!(prompt.contains("YOLO aggressive trader."));
518        assert!(prompt.contains("valid JSON"));
519    }
520
521    #[test]
522    fn user_message_includes_strategy_context() {
523        let mut config = LlmConfig::default();
524        config.prompts.selection_context = "Account 9947: conservative income pilot.".into();
525        let msg = build_user_message(&config, LlmPhase::Selection, &json!({"tick": 1})).unwrap();
526        assert!(msg.contains("Account 9947"));
527        assert!(msg.contains("\"tick\": 1"));
528    }
529
530    #[test]
531    fn empty_context_omits_strategy_block() {
532        let config = LlmConfig {
533            prompts: LlmPromptsConfig {
534                selection_context: String::new(),
535                ..Default::default()
536            },
537            ..Default::default()
538        };
539        let msg = build_user_message(&config, LlmPhase::Selection, &json!({})).unwrap();
540        assert!(!msg.contains("Strategy context:"));
541    }
542
543    #[test]
544    fn extracts_json_from_markdown_fence() {
545        let raw = r#"Here is the review:
546```json
547{"market_commentary":"ok","web_insights":[],"positions":[],"new_entries":{"recommendation":"proceed","reasoning":"fine"},"risk_alerts":[]}
548```"#;
549        let parsed = parse_llm_json_content(raw).unwrap();
550        assert_eq!(
551            parsed.pointer("/new_entries/recommendation").and_then(|v| v.as_str()),
552            Some("proceed")
553        );
554    }
555
556    #[test]
557    fn llm_review_schema_has_required_fields() {
558        let schema = llm_review_json_schema();
559        let required = schema
560            .get("required")
561            .and_then(|v| v.as_array())
562            .expect("required array");
563        assert!(required.iter().any(|v| v.as_str() == Some("positions")));
564        assert!(required.iter().any(|v| v.as_str() == Some("new_entries")));
565    }
566}