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