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
476pub 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}