diff --git a/utils/llm/README.md b/utils/llm/README.md index 713e4aa..842b6e0 100644 --- a/utils/llm/README.md +++ b/utils/llm/README.md @@ -141,7 +141,7 @@ All configuration is via environment variables: | Variable | Default | Description | |---|---|---| -| `LLM_PROVIDER` | `venice` | Provider name: `venice`, `openai`, `anthropic`, or custom | +| `LLM_PROVIDER` | `venice` | Provider name: `venice`, `groq`, `openai`, `anthropic`, or custom | | `LLM_API_KEY` | *(required)* | API key for the LLM provider | | `LLM_MODEL` | `grok-41-fast` | Model identifier | | `LLM_BASE_URL` | *(per provider)* | API base URL (not needed for anthropic) | @@ -154,6 +154,7 @@ All configuration is via environment variables: | Provider | Base URL | Default Model | Package | |---|---|---|---| | Venice.ai | `https://api.venice.ai/api/v1` | `grok-41-fast` | `openai` | +| Groq | `https://api.groq.com/openai/v1` | `openai/gpt-oss-safeguard-20b` | `openai` | | OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` | `openai` | | Anthropic | *(native API)* | `claude-haiku-4-5-20251001` | `anthropic` | | Custom | Set `LLM_BASE_URL` | Set `LLM_MODEL` | `openai` | diff --git a/utils/llm/ai_explainer.py b/utils/llm/ai_explainer.py index ae7cda7..69f330c 100644 --- a/utils/llm/ai_explainer.py +++ b/utils/llm/ai_explainer.py @@ -28,8 +28,9 @@ - Asset/token flow changes - State changes and their impact - Risk assessment (LOW/MEDIUM/HIGH/CRITICAL) -- Any concerns or notable observations +- Any concerns or notable observations""" +FORMAT_REMINDER = """ Format your response exactly as: TLDR: @@ -138,9 +139,27 @@ def _build_prompt( if simulation: parts.append(f"\n--- Simulation Results ---\n{_format_simulation_context(simulation)}") + parts.append(FORMAT_REMINDER) + return "\n".join(parts) +def _find_marker(text: str, keyword: str) -> tuple[int, int]: + """Find a section marker like 'TLDR:' or '### DETAIL' and return (start_of_marker, start_of_content). + + Handles variations: 'KEYWORD:', '## KEYWORD', '**KEYWORD**', '**KEYWORD:**', etc. + Returns (-1, -1) if not found. + """ + import re + + heading = r"#{1,4}" # fmt: skip + pattern = rf"(?:^|\n)\s*(?:{heading}\s+)?(?:\*{{2}})?{keyword}(?:\*{{2}})?[:\s]*" + match = re.search(pattern, text, re.IGNORECASE) + if match: + return match.start(), match.end() + return -1, -1 + + def _parse_explanation(raw: str) -> Explanation: """Parse LLM response into summary and detail sections. @@ -151,28 +170,23 @@ def _parse_explanation(raw: str) -> Explanation: Falls back gracefully if the LLM doesn't follow the format exactly. + Handles markdown-style headers like '### DETAIL' or '**TLDR:**'. """ - # Try to split on DETAIL: marker - upper = raw.upper() - detail_idx = upper.find("DETAIL:") - tldr_idx = upper.find("TLDR:") - - if tldr_idx != -1 and detail_idx != -1: - # Both markers found — extract each section - tldr_start = tldr_idx + len("TLDR:") - summary = raw[tldr_start:detail_idx].strip() - detail = raw[detail_idx + len("DETAIL:") :].strip() + tldr_start, tldr_content = _find_marker(raw, "TLDR") + detail_start, detail_content = _find_marker(raw, "DETAIL") + + if tldr_start != -1 and detail_start != -1: + summary = raw[tldr_content:detail_start].strip() + detail = raw[detail_content:].strip() return Explanation(summary=summary, detail=detail) - if tldr_idx != -1: - # Only TLDR found — everything after it is the summary, no detail - summary = raw[tldr_idx + len("TLDR:") :].strip() + if tldr_start != -1: + summary = raw[tldr_content:].strip() return Explanation(summary=summary, detail="") - if detail_idx != -1: - # Only DETAIL found — everything before is summary - summary = raw[:detail_idx].strip() - detail = raw[detail_idx + len("DETAIL:") :].strip() + if detail_start != -1: + summary = raw[:detail_start].strip() + detail = raw[detail_content:].strip() return Explanation(summary=summary, detail=detail) # No markers — use full response as summary (backward compatible) diff --git a/utils/llm/factory.py b/utils/llm/factory.py index ca3abca..d855337 100644 --- a/utils/llm/factory.py +++ b/utils/llm/factory.py @@ -8,6 +8,7 @@ Provider defaults: venice: base_url=https://api.venice.ai/api/v1, model=llama-3.3-70b + groq: base_url=https://api.groq.com/openai/v1, model=openai/gpt-oss-safeguard-20b openai: base_url=https://api.openai.com/v1, model=gpt-4o-mini anthropic: model=claude-haiku-4-5-20251001 (uses native Anthropic API) Custom: Set LLM_BASE_URL and LLM_MODEL explicitly. @@ -26,6 +27,10 @@ "base_url": "https://api.venice.ai/api/v1", "model": "grok-41-fast", }, + "groq": { + "base_url": "https://api.groq.com/openai/v1", + "model": "openai/gpt-oss-safeguard-20b", + }, "openai": { "base_url": "https://api.openai.com/v1", "model": "gpt-4o-mini",