plotaviz 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ """The LLM layer — optional intelligence on top of a fully working local engine.
2
+
3
+ PlotaViz is **offline-first**. The rules + scoring engine answers instantly with no provider
4
+ configured, and it is the fallback whenever an LLM call fails, times out, or returns something
5
+ that does not validate. A model is never on the critical path.
6
+
7
+ The layer does two jobs:
8
+
9
+ 1. **A second opinion on selection** when the local scores are close enough that the ranking is
10
+ effectively a coin flip.
11
+ 2. **Natural language → chart spec**, which is the only way to get from "revenue by region over
12
+ time" to a rendered chart.
13
+
14
+ Both return a *validated chart spec*. No code is ever generated, returned, or executed.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from typing import Any
21
+
22
+ from ..errors import LLMError, ProviderNotConfigured
23
+ from ..profiler import DatasetProfile
24
+ from ..spec import ChartSpec
25
+ from .anthropic import AnthropicProvider
26
+ from .base import (
27
+ KEYRING_SERVICE,
28
+ LLMResult,
29
+ Provider,
30
+ build_prompt,
31
+ delete_api_key,
32
+ extract_json,
33
+ get_api_key,
34
+ redact,
35
+ sample_rows_for,
36
+ set_api_key,
37
+ )
38
+ from .gemini import GeminiProvider
39
+ from .ollama import OllamaProvider
40
+ from .openai import OpenAIProvider
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ #: Every provider PlotaViz can use, keyed by the name stored in settings and the keyring.
45
+ PROVIDERS: dict[str, type[Provider]] = {
46
+ AnthropicProvider.name: AnthropicProvider,
47
+ OpenAIProvider.name: OpenAIProvider,
48
+ GeminiProvider.name: GeminiProvider,
49
+ OllamaProvider.name: OllamaProvider,
50
+ }
51
+
52
+ __all__ = [
53
+ "KEYRING_SERVICE",
54
+ "PROVIDERS",
55
+ "AnthropicProvider",
56
+ "GeminiProvider",
57
+ "LLMAssistant",
58
+ "LLMResult",
59
+ "OllamaProvider",
60
+ "OpenAIProvider",
61
+ "Provider",
62
+ "build_prompt",
63
+ "delete_api_key",
64
+ "extract_json",
65
+ "get_api_key",
66
+ "get_provider",
67
+ "redact",
68
+ "sample_rows_for",
69
+ "set_api_key",
70
+ ]
71
+
72
+
73
+ def get_provider(name: str, **kwargs: Any) -> Provider:
74
+ """Instantiate a provider by name.
75
+
76
+ Raises:
77
+ ProviderNotConfigured: If ``name`` is not a known provider.
78
+ """
79
+ cls = PROVIDERS.get(name.lower().strip())
80
+ if cls is None:
81
+ raise ProviderNotConfigured(
82
+ f"Unknown LLM provider {name!r}.",
83
+ hint=f"Available providers: {', '.join(PROVIDERS)}.",
84
+ )
85
+ return cls(**kwargs)
86
+
87
+
88
+ class LLMAssistant:
89
+ """Optional LLM help, with the local engine always underneath.
90
+
91
+ Args:
92
+ provider: A configured provider, or ``None`` for local-only operation.
93
+ consent: Whether the user has agreed to send schema and statistics to a remote service.
94
+ Required before any provider that leaves the machine is used. Ollama does not need it.
95
+
96
+ Example:
97
+ >>> assistant = LLMAssistant(get_provider("ollama"), consent=True)
98
+ >>> question = "revenue by region over time"
99
+ >>> result = assistant.from_question(profile, question) # doctest: +SKIP
100
+ """
101
+
102
+ def __init__(self, provider: Provider | None = None, *, consent: bool = False) -> None:
103
+ self.provider = provider
104
+ self.consent = consent
105
+
106
+ # ------------------------------------------------------------------ state
107
+
108
+ @property
109
+ def available(self) -> bool:
110
+ """Whether a request could be made right now — configured, and consented to if remote."""
111
+ if self.provider is None:
112
+ return False
113
+ if self.provider.sends_data_off_machine and not self.consent:
114
+ return False
115
+ return self.provider.is_configured()
116
+
117
+ def consent_prompt(self) -> str | None:
118
+ """The text to show before the first remote request, or ``None`` if none is needed."""
119
+ if self.provider is None or not self.provider.sends_data_off_machine:
120
+ return None
121
+ return (
122
+ f"PlotaViz will send this dataset's column names, summary statistics, and up to "
123
+ f"{5} sample rows to {self.provider.name}. The dataset itself is never uploaded.\n\n"
124
+ "To keep everything on this machine, use the Ollama provider instead."
125
+ )
126
+
127
+ # ------------------------------------------------------------------ jobs
128
+
129
+ def from_question(
130
+ self,
131
+ profile: DatasetProfile,
132
+ question: str,
133
+ *,
134
+ sample_rows: list[dict[str, Any]] | None = None,
135
+ ) -> LLMResult:
136
+ """Turn a natural-language request into a validated chart spec.
137
+
138
+ Raises:
139
+ LLMError: If no provider is available, or the request fails. There is no local
140
+ fallback for this job — a free-text question genuinely needs a model — so the
141
+ caller should show the error and leave the current chart alone.
142
+ """
143
+ if self.provider is None:
144
+ raise ProviderNotConfigured(
145
+ "The natural-language query bar needs an LLM provider.",
146
+ hint=(
147
+ "Configure one in Settings → LLM provider. For a fully local setup, install "
148
+ "Ollama and pull a model. The chart recommendations work without any of this."
149
+ ),
150
+ )
151
+ if self.provider.sends_data_off_machine and not self.consent:
152
+ raise LLMError(
153
+ "Sending data to a remote provider has not been approved for this session.",
154
+ hint=(
155
+ "Accept the consent prompt in Settings, or switch to the local Ollama provider."
156
+ ),
157
+ )
158
+
159
+ prompt_rows = sample_rows or []
160
+ result = self.provider.suggest_chart(profile, question=question)
161
+ logger.debug(
162
+ "NL query answered by %s/%s with %d sample rows",
163
+ result.provider,
164
+ result.model,
165
+ len(prompt_rows),
166
+ )
167
+ return result
168
+
169
+ def refine(
170
+ self,
171
+ profile: DatasetProfile,
172
+ candidates: list[ChartSpec],
173
+ *,
174
+ fallback: ChartSpec | None = None,
175
+ ) -> ChartSpec:
176
+ """Ask for a second opinion on an ambiguous ranking, falling back silently.
177
+
178
+ This is the failure-tolerant path: a timeout, a bad key, a malformed reply, or no
179
+ provider at all all end the same way — with the local engine's answer. The user's chart
180
+ appears either way.
181
+
182
+ Args:
183
+ profile: The dataset profile.
184
+ candidates: The local engine's ranked candidates.
185
+ fallback: Spec to return on any failure. Defaults to the top candidate.
186
+
187
+ Returns:
188
+ Either the model's validated spec or the fallback.
189
+ """
190
+ default = fallback or (candidates[0] if candidates else None)
191
+ if default is None:
192
+ raise LLMError("There are no candidate charts to refine.")
193
+ if not self.available:
194
+ return default
195
+
196
+ try:
197
+ assert self.provider is not None # narrowed by self.available
198
+ result = self.provider.suggest_chart(profile, candidates=candidates)
199
+ except LLMError as exc:
200
+ logger.info("LLM refinement failed, using the local ranking: %s", redact(str(exc)))
201
+ return default
202
+ except Exception as exc:
203
+ logger.warning("Unexpected LLM error, using the local ranking: %s", redact(str(exc)))
204
+ return default
205
+
206
+ spec = result.spec
207
+ if not spec.why:
208
+ spec.why = f"Suggested by {result.provider} ({result.model})."
209
+ else:
210
+ spec.why = f"{spec.why} (Suggested by {result.provider}.)"
211
+ return spec
@@ -0,0 +1,61 @@
1
+ """Anthropic (Claude) provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..errors import LLMError
6
+ from .base import Provider, redact
7
+
8
+
9
+ class AnthropicProvider(Provider):
10
+ """Talks to the Anthropic Messages API.
11
+
12
+ Requires the ``anthropic`` package, which is an optional extra:
13
+ ``pip install "plotaviz[llm]"``.
14
+ """
15
+
16
+ name = "anthropic"
17
+ default_model = "claude-sonnet-4-5"
18
+ available_models = (
19
+ "claude-opus-4-5",
20
+ "claude-sonnet-4-5",
21
+ "claude-haiku-4-5",
22
+ )
23
+
24
+ def complete(self, system: str, user: str, *, timeout: float) -> str:
25
+ """Send one message and return the text of the reply."""
26
+ try:
27
+ import anthropic
28
+ except ImportError as exc:
29
+ raise LLMError(
30
+ "The Anthropic provider needs the anthropic package.",
31
+ hint='Install it with: pip install "plotaviz[llm]"',
32
+ ) from exc
33
+
34
+ client = anthropic.Anthropic(api_key=self.require_key(), timeout=timeout)
35
+ try:
36
+ message = client.messages.create(
37
+ model=self.model,
38
+ max_tokens=1024,
39
+ system=system,
40
+ messages=[{"role": "user", "content": user}],
41
+ )
42
+ except anthropic.AuthenticationError as exc:
43
+ raise LLMError(
44
+ "Anthropic rejected the API key.",
45
+ hint="Check the key in Settings → LLM provider.",
46
+ ) from exc
47
+ except anthropic.RateLimitError as exc:
48
+ raise LLMError(
49
+ "Anthropic is rate limiting this key.", hint="Wait a moment and try again."
50
+ ) from exc
51
+ except anthropic.APITimeoutError as exc:
52
+ raise LLMError(
53
+ f"Anthropic did not respond within {timeout:.0f} seconds.",
54
+ hint="PlotaViz will use its own rules engine instead.",
55
+ ) from exc
56
+ except anthropic.APIError as exc:
57
+ raise LLMError("The Anthropic request failed.", hint=redact(str(exc))) from exc
58
+
59
+ return "".join(
60
+ block.text for block in message.content if getattr(block, "type", "") == "text"
61
+ )
@@ -0,0 +1,373 @@
1
+ """Provider abstraction and the prompt/validation machinery around it.
2
+
3
+ A provider's entire job is to turn two strings into one string. Everything else — building the
4
+ prompt, deciding what data is safe to send, extracting JSON from a chatty reply, validating the
5
+ returned spec against the real schema — happens here, once, so every provider behaves identically
6
+ and a new one is about thirty lines.
7
+
8
+ Two rules are structural, not stylistic:
9
+
10
+ **The model returns a chart spec, never code.** PlotaViz does not execute anything a model
11
+ produces. The reply is parsed as JSON and validated against the dataframe's actual columns; an
12
+ unknown column is a rejection with a clear message, not a traceback.
13
+
14
+ **The model sees schema, statistics, and a few sample rows.** Never the full dataset. This is a
15
+ privacy boundary and a token-cost boundary at the same time.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import re
22
+ from abc import ABC, abstractmethod
23
+ from dataclasses import dataclass
24
+ from typing import Any
25
+
26
+ from ..errors import LLMError, ProviderNotConfigured
27
+ from ..profiler import DatasetProfile
28
+ from ..spec import CHART_TYPES, ChartSpec
29
+
30
+ #: Service name used for keyring entries. One entry per provider.
31
+ KEYRING_SERVICE = "plotaviz"
32
+
33
+ #: Default per-request timeout in seconds. LLM latency must never hang the UI.
34
+ DEFAULT_TIMEOUT = 30.0
35
+
36
+ #: Sample rows included in a prompt. Small on purpose.
37
+ SAMPLE_ROWS = 5
38
+
39
+ _SYSTEM_PROMPT = f"""You are the chart-selection assistant inside PlotaViz, a desktop data \
40
+ visualization tool.
41
+
42
+ You receive a dataset's schema and summary statistics — never the dataset itself. You reply with \
43
+ a single JSON object describing which chart to draw. You never write code, never write prose \
44
+ outside the JSON, and never invent column names.
45
+
46
+ Reply with exactly this shape:
47
+
48
+ {{
49
+ "chart": one of {list(CHART_TYPES)},
50
+ "x": column name or null,
51
+ "y": column name or null,
52
+ "color": column name or null,
53
+ "agg": one of ["sum","mean","median","min","max","count","nunique"] or null,
54
+ "title": short human title or null,
55
+ "options": {{}},
56
+ "why": one or two sentences explaining the choice in plain language
57
+ }}
58
+
59
+ Rules:
60
+ - Every column name you use MUST appear in the provided schema, spelled exactly.
61
+ - Use "agg" whenever y would otherwise have several values per x.
62
+ - Prefer readable charts: do not put a 500-category column on a bar axis.
63
+ - Columns marked "is_identifier": true are keys, not measures. Do not plot them.
64
+ - Respond with the JSON object alone. No markdown fences, no commentary.
65
+ """
66
+
67
+
68
+ @dataclass
69
+ class LLMResult:
70
+ """What the LLM layer returns to the app.
71
+
72
+ Attributes:
73
+ spec: The validated chart spec.
74
+ narrative: Optional insight text the model offered alongside the choice.
75
+ provider: Which provider answered.
76
+ model: Which model answered.
77
+ raw: The raw reply, kept for the "show me what it said" affordance.
78
+ """
79
+
80
+ spec: ChartSpec
81
+ narrative: str = ""
82
+ provider: str = ""
83
+ model: str = ""
84
+ raw: str = ""
85
+
86
+
87
+ class Provider(ABC):
88
+ """Base class for an LLM provider.
89
+
90
+ Subclasses implement :meth:`complete` and declare :attr:`name` and :attr:`default_model`.
91
+ Everything else is inherited.
92
+
93
+ Args:
94
+ model: Model identifier. Falls back to :attr:`default_model`.
95
+ api_key: Explicit key. When omitted, the key is read from the OS keyring.
96
+ timeout: Per-request timeout in seconds.
97
+ """
98
+
99
+ #: Identifier used in settings and keyring entries.
100
+ name: str = "base"
101
+
102
+ #: Model used when the user has not chosen one.
103
+ default_model: str = ""
104
+
105
+ #: Models offered in the settings dialog.
106
+ available_models: tuple[str, ...] = ()
107
+
108
+ #: Whether this provider sends data off the machine. False for local runtimes.
109
+ sends_data_off_machine: bool = True
110
+
111
+ def __init__(
112
+ self,
113
+ model: str | None = None,
114
+ api_key: str | None = None,
115
+ timeout: float = DEFAULT_TIMEOUT,
116
+ ) -> None:
117
+ self.model = model or self.default_model
118
+ self._api_key = api_key
119
+ self.timeout = timeout
120
+
121
+ # ------------------------------------------------------------------ credentials
122
+
123
+ @property
124
+ def api_key(self) -> str | None:
125
+ """The API key, from the constructor or the OS keyring.
126
+
127
+ Keys are never read from environment variables or config files by default — the keyring
128
+ is the one storage location, so there is one place to audit.
129
+ """
130
+ if self._api_key:
131
+ return self._api_key
132
+ return get_api_key(self.name)
133
+
134
+ def require_key(self) -> str:
135
+ """Return the API key or explain how to set one.
136
+
137
+ Raises:
138
+ ProviderNotConfigured: If no key is stored.
139
+ """
140
+ key = self.api_key
141
+ if not key:
142
+ raise ProviderNotConfigured(
143
+ f"No API key is stored for {self.name}.",
144
+ hint="Add one in Settings → LLM provider. Keys are kept in your OS keychain.",
145
+ )
146
+ return key
147
+
148
+ def is_configured(self) -> bool:
149
+ """Whether this provider could make a request right now."""
150
+ return bool(self.api_key)
151
+
152
+ # ------------------------------------------------------------------ contract
153
+
154
+ @abstractmethod
155
+ def complete(self, system: str, user: str, *, timeout: float) -> str:
156
+ """Send one prompt and return the raw text reply.
157
+
158
+ Args:
159
+ system: System prompt.
160
+ user: User message.
161
+ timeout: Seconds to wait before giving up.
162
+
163
+ Raises:
164
+ LLMError: On any transport, auth, or API failure.
165
+ """
166
+
167
+ # ------------------------------------------------------------------ tasks
168
+
169
+ def suggest_chart(
170
+ self,
171
+ profile: DatasetProfile,
172
+ *,
173
+ question: str | None = None,
174
+ candidates: list[ChartSpec] | None = None,
175
+ ) -> LLMResult:
176
+ """Ask the model for a chart spec.
177
+
178
+ Serves both jobs the LLM layer exists for: with ``question`` it is natural-language →
179
+ spec; without one it is a second opinion on an ambiguous ranking.
180
+
181
+ Args:
182
+ profile: The dataset profile. Only its schema summary is sent.
183
+ question: The user's natural-language request, if any.
184
+ candidates: The local engine's top candidates, sent as context so the model can agree
185
+ with a good ranking rather than inventing something worse.
186
+
187
+ Returns:
188
+ A validated :class:`LLMResult`.
189
+
190
+ Raises:
191
+ LLMError: If the request fails or the reply is not a usable spec. Callers are
192
+ expected to catch this and fall back to the local engine.
193
+ """
194
+ prompt = build_prompt(profile, question=question, candidates=candidates)
195
+ raw = self.complete(_SYSTEM_PROMPT, prompt, timeout=self.timeout)
196
+ payload = extract_json(raw)
197
+ spec = ChartSpec.from_dict(payload)
198
+ spec.source = "llm"
199
+ spec.validate(list(profile.columns))
200
+ return LLMResult(
201
+ spec=spec,
202
+ narrative=str(payload.get("narrative") or payload.get("insight") or ""),
203
+ provider=self.name,
204
+ model=self.model,
205
+ raw=raw,
206
+ )
207
+
208
+ def __repr__(self) -> str:
209
+ return f"{type(self).__name__}(model={self.model!r})"
210
+
211
+
212
+ # ---------------------------------------------------------------------------- prompts
213
+
214
+
215
+ def build_prompt(
216
+ profile: DatasetProfile,
217
+ *,
218
+ question: str | None = None,
219
+ candidates: list[ChartSpec] | None = None,
220
+ sample_rows: list[dict[str, Any]] | None = None,
221
+ ) -> str:
222
+ """Assemble the user message.
223
+
224
+ Contains the schema, per-column statistics, at most :data:`SAMPLE_ROWS` example rows, and the
225
+ local engine's candidates. Deliberately does not contain the dataset.
226
+ """
227
+ summary = profile.schema_summary()
228
+ parts = [
229
+ "Dataset schema and summary statistics:",
230
+ json.dumps(summary, indent=2, default=str),
231
+ ]
232
+
233
+ if sample_rows:
234
+ parts += [
235
+ "",
236
+ f"A sample of {min(len(sample_rows), SAMPLE_ROWS)} rows (illustrative only):",
237
+ json.dumps(sample_rows[:SAMPLE_ROWS], indent=2, default=str),
238
+ ]
239
+
240
+ if candidates:
241
+ ranked = [
242
+ {"chart": c.chart, "x": c.x, "y": c.y, "color": c.color, "score": round(c.score, 3)}
243
+ for c in candidates[:5]
244
+ ]
245
+ parts += [
246
+ "",
247
+ "PlotaViz's own rules engine ranked these candidates. Their scores were close, "
248
+ "which is why you are being asked:",
249
+ json.dumps(ranked, indent=2),
250
+ ]
251
+
252
+ if question:
253
+ parts += [
254
+ "",
255
+ f"The user asked for: {question!r}",
256
+ "",
257
+ "Return the chart spec that answers it.",
258
+ ]
259
+ else:
260
+ parts += ["", "Return the single chart spec that best reveals what is in this dataset."]
261
+
262
+ return "\n".join(parts)
263
+
264
+
265
+ def sample_rows_for(df: Any, n: int = SAMPLE_ROWS) -> list[dict[str, Any]]:
266
+ """Take a few example rows in a JSON-safe form.
267
+
268
+ Kept tiny by design — enough for the model to see value formats, not enough to constitute
269
+ sending someone's data anywhere.
270
+ """
271
+ try:
272
+ head = df.head(n)
273
+ return json.loads(head.to_json(orient="records", date_format="iso")) or []
274
+ except Exception:
275
+ return []
276
+
277
+
278
+ def extract_json(text: str) -> dict[str, Any]:
279
+ """Pull the JSON object out of a model reply.
280
+
281
+ Models add markdown fences and preambles no matter how firmly they are told not to, so this
282
+ tolerates both rather than failing on formatting.
283
+
284
+ Raises:
285
+ LLMError: If no JSON object can be found or parsed.
286
+ """
287
+ if not text or not text.strip():
288
+ raise LLMError("The model returned an empty response.")
289
+
290
+ cleaned = text.strip()
291
+ fenced = re.search(r"```(?:json)?\s*(.+?)\s*```", cleaned, re.DOTALL)
292
+ if fenced:
293
+ cleaned = fenced.group(1).strip()
294
+
295
+ try:
296
+ payload = json.loads(cleaned)
297
+ except json.JSONDecodeError:
298
+ start, end = cleaned.find("{"), cleaned.rfind("}")
299
+ if start == -1 or end <= start:
300
+ raise LLMError(
301
+ "The model's reply did not contain a chart specification.",
302
+ hint="Try rephrasing, or use the ranked recommendations from the local engine.",
303
+ ) from None
304
+ try:
305
+ payload = json.loads(cleaned[start : end + 1])
306
+ except json.JSONDecodeError as exc:
307
+ raise LLMError("The model's reply was not valid JSON.", hint=str(exc)) from exc
308
+
309
+ if not isinstance(payload, dict):
310
+ raise LLMError("The model returned JSON, but not a chart specification object.")
311
+ return payload
312
+
313
+
314
+ # ---------------------------------------------------------------------------- keyring
315
+
316
+
317
+ def get_api_key(provider: str) -> str | None:
318
+ """Read a provider's API key from the OS keyring.
319
+
320
+ Returns ``None`` when nothing is stored or no keyring backend is available — a missing key is
321
+ an ordinary state, since PlotaViz works fully offline.
322
+ """
323
+ try:
324
+ import keyring
325
+
326
+ return keyring.get_password(KEYRING_SERVICE, provider)
327
+ except Exception:
328
+ return None
329
+
330
+
331
+ def set_api_key(provider: str, key: str) -> None:
332
+ """Store a provider's API key in the OS keyring.
333
+
334
+ Raises:
335
+ LLMError: If no keyring backend is available. PlotaViz does not fall back to writing the
336
+ key to a file — that is exactly the failure mode the keyring exists to prevent.
337
+ """
338
+ try:
339
+ import keyring
340
+
341
+ keyring.set_password(KEYRING_SERVICE, provider, key)
342
+ except Exception as exc:
343
+ raise LLMError(
344
+ "Could not store the API key in your system keychain.",
345
+ hint=(
346
+ "PlotaViz will not write keys to disk in plain text. On Linux, install a Secret "
347
+ f"Service backend such as gnome-keyring or kwallet. Underlying error: {exc}"
348
+ ),
349
+ ) from exc
350
+
351
+
352
+ def delete_api_key(provider: str) -> None:
353
+ """Remove a provider's stored API key. Missing entries are not an error."""
354
+ try:
355
+ import keyring
356
+
357
+ keyring.delete_password(KEYRING_SERVICE, provider)
358
+ except Exception:
359
+ pass
360
+
361
+
362
+ def redact(text: str) -> str:
363
+ """Mask anything that looks like an API key before it reaches a log or an error dialog."""
364
+ patterns = (
365
+ r"sk-ant-[A-Za-z0-9\-_]{8,}",
366
+ r"sk-[A-Za-z0-9]{16,}",
367
+ r"AIza[0-9A-Za-z\-_]{20,}",
368
+ r"Bearer\s+[A-Za-z0-9\-._~+/]{16,}",
369
+ )
370
+ out = text
371
+ for pattern in patterns:
372
+ out = re.sub(pattern, "[redacted]", out)
373
+ return out
@@ -0,0 +1,51 @@
1
+ """Google Gemini provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..errors import LLMError
6
+ from .base import Provider, redact
7
+
8
+
9
+ class GeminiProvider(Provider):
10
+ """Talks to the Google Gemini API.
11
+
12
+ Requires the ``google-genai`` package: ``pip install "plotaviz[llm]"``.
13
+ """
14
+
15
+ name = "gemini"
16
+ default_model = "gemini-2.0-flash"
17
+ available_models = ("gemini-2.5-pro", "gemini-2.0-flash", "gemini-2.0-flash-lite")
18
+
19
+ def complete(self, system: str, user: str, *, timeout: float) -> str:
20
+ """Send one message and return the text of the reply."""
21
+ try:
22
+ from google import genai
23
+ from google.genai import types
24
+ except ImportError as exc:
25
+ raise LLMError(
26
+ "The Gemini provider needs the google-genai package.",
27
+ hint='Install it with: pip install "plotaviz[llm]"',
28
+ ) from exc
29
+
30
+ client = genai.Client(api_key=self.require_key())
31
+ try:
32
+ response = client.models.generate_content(
33
+ model=self.model,
34
+ contents=user,
35
+ config=types.GenerateContentConfig(
36
+ system_instruction=system,
37
+ response_mime_type="application/json",
38
+ max_output_tokens=1024,
39
+ http_options=types.HttpOptions(timeout=int(timeout * 1000)),
40
+ ),
41
+ )
42
+ except Exception as exc:
43
+ message = str(exc)
44
+ if "API key" in message or "PERMISSION_DENIED" in message:
45
+ raise LLMError(
46
+ "Google rejected the API key.",
47
+ hint="Check the key in Settings → LLM provider.",
48
+ ) from exc
49
+ raise LLMError("The Gemini request failed.", hint=redact(message)) from exc
50
+
51
+ return response.text or ""