From 08b12e2ce2e1c02e2417e0824af041aff8e79c19 Mon Sep 17 00:00:00 2001 From: drjones Date: Tue, 7 Jul 2026 17:20:31 +0000 Subject: [PATCH] Wire AI features to Claude (claude-sonnet-5); fix key detection + retired model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Anthropic path could never activate: it gated on `not ANTHROPIC_API_KEY.startswith("sk-ant-")`, but real Anthropic keys start with `sk-ant-`, so any real key was treated as a placeholder and every request fell back to mock. It also targeted the retired `claude-3-haiku-20240307`. - ai_translator.py: add `_real_key()` placeholder detection (rejects `sk-ant-...`, `changeme`, `your-`, etc. — accepts real secrets), centralize provider gating in `_ai_enabled()`, and point all three AI features (finding translation, security coach, attack-path narrative) at `claude-sonnet-5` with thinking disabled for fast structured output. OpenAI kept as a secondary provider. - config.py / .env.example: default AI_PROVIDER to anthropic. Mock mode still works with no key configured; dropping in a real ANTHROPIC_API_KEY now actually enables live Claude. Co-Authored-By: Claude Fable 5 --- backend/.env.example | 8 +- backend/app/core/config.py | 2 +- backend/app/services/ai_translator.py | 112 ++++++++++++++++++-------- 3 files changed, 84 insertions(+), 38 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index fb0bec6..690dab9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,10 +8,12 @@ SYNC_DATABASE_URL=postgresql://trustos:trustos_dev@postgres:5432/trustos SECRET_KEY=changeme-use-openssl-rand-hex-32-in-production ACCESS_TOKEN_EXPIRE_MINUTES=480 -# AI -OPENAI_API_KEY=sk-... +# AI — defaults to Anthropic/Claude (model: claude-sonnet-5). +# Leave the placeholders as-is to run in mock mode (all AI features return +# canned demo responses). Drop in a real key to enable live AI. +AI_PROVIDER=anthropic ANTHROPIC_API_KEY=sk-ant-... -AI_PROVIDER=openai +OPENAI_API_KEY=sk-... # External APIs HIBP_API_KEY= diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 5e29647..f331d88 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -16,7 +16,7 @@ class Settings(BaseSettings): CORS_ORIGINS: Optional[str] = None # comma-separated extra allowed origins ACCESS_TOKEN_EXPIRE_MINUTES: int = 480 - AI_PROVIDER: str = "openai" + AI_PROVIDER: str = "anthropic" OPENAI_API_KEY: Optional[str] = None ANTHROPIC_API_KEY: Optional[str] = None diff --git a/backend/app/services/ai_translator.py b/backend/app/services/ai_translator.py index bc9a3d1..c4e37db 100644 --- a/backend/app/services/ai_translator.py +++ b/backend/app/services/ai_translator.py @@ -12,7 +12,51 @@ import logging logger = logging.getLogger(__name__) -TRANSLATION_SYSTEM_PROMPT = """You are TrustOS, an AI cyber resilience advisor. +# Current Claude model for all AI features. Sonnet 5 is a strong fit for this +# high-volume translation/classification work — near-Opus quality at lower cost. +CLAUDE_MODEL = "claude-sonnet-5" +OPENAI_MODEL = "gpt-4o-mini" + +_PLACEHOLDER_MARKERS = ("...", "changeme", "your-", "replace") + + +def _real_key(value: Optional[str]) -> Optional[str]: + """Return the key only if it looks like a real secret (not a placeholder). + + The .env ships with placeholders like ``sk-ant-...`` and ``sk-...``; a real + key must be present and contain none of the placeholder markers. (The old + code checked ``startswith("sk-ant-")``, which matches *real* Anthropic keys + too, so it could never use one.) + """ + if not value: + return None + lowered = value.lower() + if any(marker in lowered for marker in _PLACEHOLDER_MARKERS): + return None + return value + + +def _anthropic_key(): + from app.core.config import settings + return _real_key(settings.ANTHROPIC_API_KEY) + + +def _openai_key(): + from app.core.config import settings + return _real_key(settings.OPENAI_API_KEY) + + +def _ai_enabled() -> bool: + """True when a real API key is configured for the active provider.""" + from app.core.config import settings + if settings.AI_PROVIDER == "anthropic": + return _anthropic_key() is not None + if settings.AI_PROVIDER == "openai": + return _openai_key() is not None + return False + + +TRANSLATION_SYSTEM_PROMPT = """You are TrustOS, an AI cyber resilience advisor. Your role is to translate technical cybersecurity findings into clear, plain-English business impact statements for executive and non-technical audiences. @@ -37,11 +81,24 @@ async def _call_llm(prompt: str) -> Optional[str]: """Call the configured LLM provider. Returns raw text response.""" from app.core.config import settings try: - if settings.AI_PROVIDER == "openai" and settings.OPENAI_API_KEY and not settings.OPENAI_API_KEY.startswith("sk-..."): + anthropic_key = _anthropic_key() + openai_key = _openai_key() + if settings.AI_PROVIDER == "anthropic" and anthropic_key: + from anthropic import AsyncAnthropic + client = AsyncAnthropic(api_key=anthropic_key) + resp = await client.messages.create( + model=CLAUDE_MODEL, + max_tokens=1024, + thinking={"type": "disabled"}, # fast, structured JSON output + system=TRANSLATION_SYSTEM_PROMPT, + messages=[{"role": "user", "content": prompt}], + ) + return resp.content[0].text + elif settings.AI_PROVIDER == "openai" and openai_key: from openai import AsyncOpenAI - client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) + client = AsyncOpenAI(api_key=openai_key) resp = await client.chat.completions.create( - model="gpt-4o-mini", + model=OPENAI_MODEL, messages=[ {"role": "system", "content": TRANSLATION_SYSTEM_PROMPT}, {"role": "user", "content": prompt} @@ -50,16 +107,6 @@ async def _call_llm(prompt: str) -> Optional[str]: response_format={"type": "json_object"}, ) return resp.choices[0].message.content - elif settings.AI_PROVIDER == "anthropic" and settings.ANTHROPIC_API_KEY and not settings.ANTHROPIC_API_KEY.startswith("sk-ant-"): - from anthropic import AsyncAnthropic - client = AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY) - resp = await client.messages.create( - model="claude-3-haiku-20240307", - max_tokens=1024, - system=TRANSLATION_SYSTEM_PROMPT, - messages=[{"role": "user", "content": prompt}], - ) - return resp.content[0].text else: logger.info("No valid AI provider configured — using mock translation") return _generate_mock_translation(prompt) @@ -137,11 +184,24 @@ Answer in 2-4 sentences. Be specific to this finding. Use plain English.""" from app.core.config import settings try: - if settings.AI_PROVIDER == "openai" and settings.OPENAI_API_KEY and not settings.OPENAI_API_KEY.startswith("sk-..."): + anthropic_key = _anthropic_key() + openai_key = _openai_key() + if settings.AI_PROVIDER == "anthropic" and anthropic_key: + from anthropic import AsyncAnthropic + client = AsyncAnthropic(api_key=anthropic_key) + resp = await client.messages.create( + model=CLAUDE_MODEL, + max_tokens=256, + thinking={"type": "disabled"}, + system=system, + messages=[{"role": "user", "content": prompt}], + ) + return resp.content[0].text + elif settings.AI_PROVIDER == "openai" and openai_key: from openai import AsyncOpenAI - client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) + client = AsyncOpenAI(api_key=openai_key) resp = await client.chat.completions.create( - model="gpt-4o-mini", + model=OPENAI_MODEL, messages=[ {"role": "system", "content": system}, {"role": "user", "content": prompt} @@ -149,16 +209,6 @@ Answer in 2-4 sentences. Be specific to this finding. Use plain English.""" temperature=0.5, ) return resp.choices[0].message.content - elif settings.AI_PROVIDER == "anthropic" and settings.ANTHROPIC_API_KEY and not settings.ANTHROPIC_API_KEY.startswith("sk-ant-"): - from anthropic import AsyncAnthropic - client = AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY) - resp = await client.messages.create( - model="claude-3-haiku-20240307", - max_tokens=256, - system=system, - messages=[{"role": "user", "content": prompt}], - ) - return resp.content[0].text except Exception as e: logger.error(f"AI coach call failed: {e}") @@ -185,13 +235,7 @@ async def generate_attack_path_narrative(finding_id: str): if not finding: return - from app.core.config import settings - use_mock = not ( - (settings.AI_PROVIDER == "openai" and settings.OPENAI_API_KEY and not settings.OPENAI_API_KEY.startswith("sk-...")) or - (settings.AI_PROVIDER == "anthropic" and settings.ANTHROPIC_API_KEY and not settings.ANTHROPIC_API_KEY.startswith("sk-ant-")) - ) - - if use_mock: + if not _ai_enabled(): raw = _generate_mock_attack_path(finding) else: prompt = f"""Create an attack path for this vulnerability: