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