File size: 6,890 Bytes
d28ac45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
"""Provider-agnostic hosted-LLM client for the enhancer's optional "AI" engine.

Keys are supplied per-request by the user (never stored server-side). Supports
OpenAI-compatible providers (OpenAI, DeepSeek, OpenRouter, Ollama), Anthropic,
and Google Gemini. Every call is best-effort; failures raise so the caller can
fall back to the deterministic engine.
"""
import re

DEFAULT_MODELS = {
    "openai": "gpt-4o-mini",
    "anthropic": "claude-haiku-4-5-20251001",
    "deepseek": "deepseek-chat",
    "openrouter": "openai/gpt-4o-mini",
    "gemini": "gemini-1.5-flash",
    "ollama": "llama3.2",
}

# Providers that speak the OpenAI /chat/completions dialect.
_OPENAI_BASE = {
    "openai": "https://api.openai.com/v1",
    "deepseek": "https://api.deepseek.com/v1",
    "openrouter": "https://openrouter.ai/api/v1",
}

SUPPORTED = set(DEFAULT_MODELS)


def _system_prompt(language: str) -> str:
    return (
        f"You are a secure-coding assistant. Rewrite the following {language} code "
        "to fix every security vulnerability while preserving its behaviour. Return "
        "ONLY the corrected code, with no explanations, no commentary, and no "
        "markdown code fences."
    )


def strip_code_fences(text: str) -> str:
    text = (text or "").strip()
    fenced = re.match(r"^```[A-Za-z0-9_+-]*\n(.*)\n?```$", text, re.DOTALL)
    if fenced:
        return fenced.group(1).strip()
    text = re.sub(r"^```[A-Za-z0-9_+-]*\n?", "", text)
    text = re.sub(r"\n?```$", "", text)
    return text.strip()


def _ollama_base(base_url: str) -> str:
    base = (base_url or "http://localhost:11434/v1").strip().rstrip("/")
    if not re.match(r"^https?://", base):
        raise ValueError("Ollama base URL must start with http:// or https://")
    if not base.endswith("/v1"):
        base = base + "/v1"
    return base


def _openai_chat(base, api_key, model, system, user, max_tokens, timeout, referer=None):
    import requests

    headers = {"content-type": "application/json"}
    if api_key:
        headers["Authorization"] = f"Bearer {api_key}"
    if referer:  # OpenRouter likes these; harmless elsewhere.
        headers["HTTP-Referer"] = referer
        headers["X-Title"] = "FortiScan"
    resp = requests.post(
        f"{base}/chat/completions",
        headers=headers,
        json={
            "model": model,
            "max_tokens": max_tokens,
            "messages": [
                {"role": "system", "content": system},
                {"role": "user", "content": user},
            ],
        },
        timeout=timeout,
    )
    resp.raise_for_status()
    return resp.json()["choices"][0]["message"]["content"]


def _anthropic(api_key, model, system, user, max_tokens, timeout):
    import requests

    resp = requests.post(
        "https://api.anthropic.com/v1/messages",
        headers={
            "x-api-key": api_key,
            "anthropic-version": "2023-06-01",
            "content-type": "application/json",
        },
        json={
            "model": model,
            "max_tokens": max_tokens,
            "system": system,
            "messages": [{"role": "user", "content": user}],
        },
        timeout=timeout,
    )
    resp.raise_for_status()
    return resp.json()["content"][0]["text"]


def _gemini(api_key, model, system, user, max_tokens, timeout):
    import requests

    url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
    resp = requests.post(
        url,
        headers={"content-type": "application/json", "x-goog-api-key": api_key},
        json={
            "system_instruction": {"parts": [{"text": system}]},
            "contents": [{"parts": [{"text": user}]}],
            "generationConfig": {"maxOutputTokens": max_tokens},
        },
        timeout=timeout,
    )
    resp.raise_for_status()
    data = resp.json()
    return data["candidates"][0]["content"]["parts"][0]["text"]


def call_llm(provider, api_key="", model="", code="", language="python",
             base_url="", max_tokens=2048, timeout=45):
    """Return the LLM's rewritten code (fences stripped). Raises on failure."""
    provider = (provider or "").strip().lower()
    if provider not in SUPPORTED:
        raise ValueError(f"Unsupported provider: {provider!r}")
    model = (model or "").strip() or DEFAULT_MODELS[provider]
    system = _system_prompt(language)

    if provider in _OPENAI_BASE:
        referer = "https://fortiscan.app" if provider == "openrouter" else None
        text = _openai_chat(_OPENAI_BASE[provider], api_key, model, system, code,
                            max_tokens, timeout, referer=referer)
    elif provider == "ollama":
        text = _openai_chat(_ollama_base(base_url), "", model, system, code, max_tokens, timeout)
    elif provider == "anthropic":
        text = _anthropic(api_key, model, system, code, max_tokens, timeout)
    elif provider == "gemini":
        text = _gemini(api_key, model, system, code, max_tokens, timeout)
    else:  # pragma: no cover - guarded above
        raise ValueError(f"Unsupported provider: {provider!r}")

    return strip_code_fences(text)


def test_llm(provider, api_key="", model="", base_url="", timeout=15):
    """Live connectivity/auth check. Returns (ok: bool, message: str)."""
    import requests

    provider = (provider or "").strip().lower()
    if provider not in SUPPORTED:
        return False, f"Unsupported provider: {provider}"
    if provider != "ollama" and not api_key:
        return False, "An API key is required for this provider."

    model = (model or "").strip() or DEFAULT_MODELS[provider]
    try:
        # Minimal generation verifies connectivity, auth, and model availability.
        call_llm(provider, api_key=api_key, model=model, code="print(1)",
                 language="python", base_url=base_url, max_tokens=16, timeout=timeout)
        return True, f"Connected — {provider} / {model} is live."
    except requests.HTTPError as e:
        status = e.response.status_code if e.response is not None else "?"
        detail = ""
        if e.response is not None:
            try:
                body = e.response.json()
                detail = (body.get("error", {}) or {}).get("message", "") if isinstance(body.get("error"), dict) else str(body.get("error", ""))
            except Exception:
                detail = e.response.text[:200]
        hint = " (check the API key)" if status in (401, 403) else " (check the model name)" if status == 404 else ""
        return False, f"HTTP {status}{hint}: {detail or 'request rejected'}"
    except requests.ConnectionError:
        return False, "Connection failed — is the endpoint reachable? (For Ollama, is it running?)"
    except requests.Timeout:
        return False, "Timed out contacting the provider."
    except Exception as e:
        return False, f"{e.__class__.__name__}: {e}"