docugraph 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.
Files changed (37) hide show
  1. docugraph/__init__.py +24 -0
  2. docugraph/_version.py +28 -0
  3. docugraph/agents/__init__.py +27 -0
  4. docugraph/agents/coding_agent.py +532 -0
  5. docugraph/agents/tools/__init__.py +62 -0
  6. docugraph/agents/tools/graph_query.py +203 -0
  7. docugraph/agents/tools/memory_ops.py +305 -0
  8. docugraph/agents/tools/search_code.py +305 -0
  9. docugraph/agents/tools/search_docs.py +148 -0
  10. docugraph/core/__init__.py +34 -0
  11. docugraph/core/config.py +183 -0
  12. docugraph/core/embeddings.py +394 -0
  13. docugraph/core/llm.py +892 -0
  14. docugraph/core/models.py +145 -0
  15. docugraph/ingestion/__init__.py +64 -0
  16. docugraph/ingestion/api_docs.py +646 -0
  17. docugraph/ingestion/chunker.py +382 -0
  18. docugraph/ingestion/crawler.py +305 -0
  19. docugraph/ingestion/git_indexer.py +556 -0
  20. docugraph/ingestion/local_files.py +516 -0
  21. docugraph/interfaces/__init__.py +1 -0
  22. docugraph/interfaces/cli.py +983 -0
  23. docugraph/interfaces/mcp_server.py +918 -0
  24. docugraph/retrieval/__init__.py +54 -0
  25. docugraph/retrieval/context_builder.py +522 -0
  26. docugraph/retrieval/hybrid_search.py +534 -0
  27. docugraph/retrieval/reranker.py +532 -0
  28. docugraph/storage/__init__.py +14 -0
  29. docugraph/storage/graph_store.py +602 -0
  30. docugraph/storage/memory_store.py +529 -0
  31. docugraph/storage/vector_store.py +367 -0
  32. docugraph-0.1.0.dist-info/METADATA +354 -0
  33. docugraph-0.1.0.dist-info/RECORD +37 -0
  34. docugraph-0.1.0.dist-info/WHEEL +4 -0
  35. docugraph-0.1.0.dist-info/entry_points.txt +3 -0
  36. docugraph-0.1.0.dist-info/licenses/LICENSE +201 -0
  37. docugraph-0.1.0.dist-info/licenses/NOTICE +10 -0
docugraph/core/llm.py ADDED
@@ -0,0 +1,892 @@
1
+ """LLM provider abstraction for DocuGraph AI.
2
+
3
+ Supports LLM-agnostic configuration with local-first priority:
4
+ - Ollama (local, default if available)
5
+ - OpenAI (cloud)
6
+ - Anthropic (cloud)
7
+
8
+ Usage:
9
+ from docugraph.core.llm import get_llm_client, is_ollama_available
10
+
11
+ # Auto-detect best available provider
12
+ client = get_llm_client()
13
+
14
+ # Or specify explicitly
15
+ client = get_llm_client(provider=LLMProvider.OLLAMA)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from abc import ABC, abstractmethod
22
+ from typing import Any, cast
23
+
24
+ import httpx
25
+
26
+ from docugraph.core.config import LLMProvider, get_config
27
+
28
+ # One literal each, because the preflight check and the client it builds have
29
+ # to agree on which server and which model they are talking about.
30
+ #
31
+ # These are the only model names left in this package, and they are here for the
32
+ # one provider that can be asked what it has. Ollama runs on this machine, keeps
33
+ # its models in a local store, and answers `GET /api/tags` with the list;
34
+ # `ollama pull llama3.2` is a command any reader can run. A cloud provider
35
+ # cannot be treated the same way: a name written here is a name the provider
36
+ # will retire on its own schedule, and after that date every call fails with a
37
+ # 404 that says nothing about which line of what config put it there. So the
38
+ # cloud providers have no default at all -- they take the model from
39
+ # `llm.model` and say so when it is missing. See `_cloud_model_problem`.
40
+ OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434"
41
+ OLLAMA_DEFAULT_MODEL = "llama3.2"
42
+
43
+
44
+ class LLMProviderBase(ABC):
45
+ """Abstract base class for LLM providers."""
46
+
47
+ @property
48
+ @abstractmethod
49
+ def provider_name(self) -> str:
50
+ """Return the provider name."""
51
+ ...
52
+
53
+ @property
54
+ @abstractmethod
55
+ def model_name(self) -> str:
56
+ """Return the model name being used."""
57
+ ...
58
+
59
+ @abstractmethod
60
+ async def generate(
61
+ self,
62
+ prompt: str,
63
+ system_prompt: str | None = None,
64
+ temperature: float | None = None,
65
+ max_tokens: int = 4096,
66
+ ) -> str:
67
+ """Generate a response from the LLM.
68
+
69
+ Args:
70
+ prompt: The user prompt
71
+ system_prompt: Optional system prompt
72
+ temperature: Sampling temperature, or None to leave the provider's
73
+ own default in place. None is the default because current
74
+ reasoning models reject the parameter outright when the value
75
+ is not theirs, so sending one the user never asked for is a
76
+ call that cannot succeed.
77
+ max_tokens: Maximum tokens in response
78
+
79
+ Returns:
80
+ Generated text response
81
+ """
82
+ ...
83
+
84
+ @abstractmethod
85
+ def get_graphiti_client(self) -> Any:
86
+ """Get a Graphiti-compatible LLM client.
87
+
88
+ Returns:
89
+ A client compatible with Graphiti's llm_client parameter
90
+ """
91
+ ...
92
+
93
+
94
+ def _make_resilient_graphiti_client(llm_config: Any) -> Any:
95
+ """Build a Graphiti OpenAIGenericClient that tolerates loose JSON shapes.
96
+
97
+ Graphiti parses each extraction response as ``Model(**json.loads(text))``.
98
+ Many OpenAI-compatible endpoints (Ollama/llama.cpp-served models) do not
99
+ strictly honor the requested ``json_schema`` and return a bare JSON array
100
+ (e.g. ``[{...}]``) instead of the wrapping object
101
+ (``{"extracted_entities": [...]}``). That makes ``Model(**list)`` raise
102
+ ``TypeError: argument after ** must be a mapping, not list``.
103
+
104
+ This subclass coerces such a bare list back into the single list-typed
105
+ field the target ``response_model`` declares, so local-LLM graph
106
+ extraction works without requiring a strict-schema cloud model.
107
+ """
108
+ from graphiti_core.llm_client.openai_generic_client import OpenAIGenericClient
109
+
110
+ if not llm_config.model:
111
+ # There used to be a `or "gpt-4.1-mini"` here, which was the worst of both
112
+ # worlds: it named an OpenAI model on a client built only for local
113
+ # OpenAI-compatible servers, so an Ollama user without a model configured
114
+ # got a request for a name Ollama could not have, and the error said
115
+ # "model not found" rather than "no model configured".
116
+ raise ValueError(
117
+ "A Graphiti LLM client needs a model name, and this one was built "
118
+ "without one. OllamaLLM/OpenAILLM/AnthropicLLM all resolve a model "
119
+ "in their constructors, so reaching here means a client was built "
120
+ "directly from an empty LLMConfig."
121
+ )
122
+
123
+ class ResilientGraphitiClient(OpenAIGenericClient):
124
+ async def _generate_response(
125
+ self,
126
+ messages: Any,
127
+ response_model: Any = None,
128
+ max_tokens: Any = None,
129
+ model_size: Any = None, # noqa: ARG002 -- Graphiti-mandated signature
130
+ ) -> dict[str, Any]:
131
+ # Reimplements the parent request loop with tolerant JSON parsing:
132
+ # OpenAI-compatible models served via Ollama/llama.cpp often wrap
133
+ # their output in prose or a ```json fence, or emit a bare list,
134
+ # which the parent's strict json.loads(...) + Model(**result) path
135
+ # can't handle. We extract the JSON payload and coerce its shape.
136
+ import openai as _openai
137
+ from graphiti_core.llm_client.errors import EmptyResponseError, RateLimitError
138
+
139
+ openai_messages: list[dict[str, str]] = []
140
+ for m in messages:
141
+ m.content = self._clean_input(m.content)
142
+ if m.role in ("user", "system"):
143
+ openai_messages.append({"role": m.role, "content": m.content})
144
+
145
+ token_limit: int = int(max_tokens or self.max_tokens)
146
+ # `max_tokens` and not `max_completion_tokens`: this client only ever
147
+ # talks to an OpenAI-*compatible* local server (Ollama, llama.cpp),
148
+ # which is the endpoint that kept `max_tokens`. The managed OpenAI API
149
+ # is reached through graphiti_core's own OpenAIClient, not here.
150
+ # `temperature` is omitted rather than sent as None when unset, so an
151
+ # unset value cannot reach the server as a null the server must
152
+ # interpret.
153
+ request: dict[str, Any] = {
154
+ "model": self.model,
155
+ "messages": openai_messages,
156
+ "max_tokens": token_limit,
157
+ "response_format": self._build_response_format(response_model),
158
+ }
159
+ if self.temperature is not None:
160
+ request["temperature"] = self.temperature
161
+ try:
162
+ response = await self.client.chat.completions.create(**request)
163
+ text = response.choices[0].message.content or ""
164
+ if not text:
165
+ raise EmptyResponseError("LLM returned an empty response")
166
+ parsed = self._loads_tolerant(self._strip_code_fences(text))
167
+ return self._coerce_to_schema(parsed, response_model)
168
+ except _openai.RateLimitError as e:
169
+ raise RateLimitError from e
170
+
171
+ @staticmethod
172
+ def _loads_tolerant(text: str) -> Any:
173
+ """json.loads, but fall back to the first JSON value embedded in prose."""
174
+ import json as _json
175
+ import re as _re
176
+
177
+ try:
178
+ return _json.loads(text)
179
+ except _json.JSONDecodeError:
180
+ # Grab the outermost {...} or [...] block a chatty model wrapped
181
+ # its answer in (e.g. a reasoning preamble before the JSON).
182
+ match = _re.search(r"(\{.*\}|\[.*\])", text, _re.DOTALL)
183
+ if match:
184
+ return _json.loads(match.group(1))
185
+ raise
186
+
187
+ @classmethod
188
+ def _coerce_to_schema(cls, result: Any, response_model: Any) -> dict[str, Any]:
189
+ if response_model is None:
190
+ return result if isinstance(result, dict) else {}
191
+
192
+ # Find the model's sole list-typed field and its item model, if any.
193
+ list_field, item_model = cls._list_field(response_model)
194
+
195
+ # A bare list: attach it to that list field.
196
+ if isinstance(result, list):
197
+ if list_field is None:
198
+ return {}
199
+ result = {list_field: result}
200
+
201
+ if not isinstance(result, dict):
202
+ return {}
203
+
204
+ # Normalize each list item's keys to the item model's field names
205
+ # (e.g. models that emit `entity_name` where the schema wants
206
+ # `name`). Only *missing* fields are filled, so exact-match fields
207
+ # like `source_entity_name` are never disturbed.
208
+ if list_field and item_model and isinstance(result.get(list_field), list):
209
+ result[list_field] = [
210
+ cls._normalize_item(item, item_model) if isinstance(item, dict) else item
211
+ for item in result[list_field]
212
+ ]
213
+ return result
214
+
215
+ @staticmethod
216
+ def _list_field(model: Any) -> tuple[str | None, Any]:
217
+ import typing
218
+
219
+ for name, field in model.model_fields.items():
220
+ ann = field.annotation
221
+ if "list" in str(ann).lower():
222
+ args = typing.get_args(ann)
223
+ item_model = args[0] if args and hasattr(args[0], "model_fields") else None
224
+ return name, item_model
225
+ return None, None
226
+
227
+ @staticmethod
228
+ def _normalize_item(item: dict[str, Any], item_model: Any) -> dict[str, Any]:
229
+ fields = set(item_model.model_fields)
230
+ present = {k for k in item if k in fields}
231
+ out = dict(item)
232
+ for field_name in fields - present:
233
+ # Find an unclaimed key that clearly aliases this field, e.g.
234
+ # `entity_name` -> `name`. Suffix match keeps it conservative.
235
+ for key in list(out):
236
+ if key in fields:
237
+ continue
238
+ if key.endswith("_" + field_name) or key == field_name:
239
+ out[field_name] = out.pop(key)
240
+ break
241
+ return out
242
+
243
+ return ResilientGraphitiClient(config=llm_config)
244
+
245
+
246
+ def _graphiti_temperature(value: float | None) -> float:
247
+ """Hand Graphiti a temperature that may be unset.
248
+
249
+ Graphiti annotates ``LLMConfig.temperature`` as ``float`` and then branches
250
+ on ``temperature is not None`` before putting it on the request -- so None is
251
+ how its own implementation says "leave the model's default alone", even
252
+ though the signature does not admit it. The conversion lives here so that
253
+ one mismatch is described once rather than at three call sites.
254
+
255
+ Passing its 1.0 default instead is not equivalent: Graphiti skips the
256
+ parameter only for the reasoning models it recognises by prefix, and a
257
+ temperature nobody chose is still a temperature the model may reject.
258
+ """
259
+ return cast("float", value)
260
+
261
+
262
+ class OllamaLLM(LLMProviderBase):
263
+ """Ollama LLM provider for local inference."""
264
+
265
+ def __init__(
266
+ self,
267
+ model: str = OLLAMA_DEFAULT_MODEL,
268
+ base_url: str = OLLAMA_DEFAULT_BASE_URL,
269
+ temperature: float | None = None,
270
+ small_model: str | None = None,
271
+ ) -> None:
272
+ self._model = model
273
+ self._base_url = base_url
274
+ self._temperature = temperature
275
+ # Graphiti's cheap pass runs on the same local model unless told
276
+ # otherwise, which is the honest default for a single-GPU box.
277
+ self._small_model = small_model or model
278
+
279
+ @property
280
+ def provider_name(self) -> str:
281
+ return "ollama"
282
+
283
+ @property
284
+ def model_name(self) -> str:
285
+ return self._model
286
+
287
+ async def generate(
288
+ self,
289
+ prompt: str,
290
+ system_prompt: str | None = None,
291
+ temperature: float | None = None,
292
+ max_tokens: int = 4096,
293
+ ) -> str:
294
+ """Generate using Ollama API."""
295
+ async with httpx.AsyncClient() as client:
296
+ messages = []
297
+ if system_prompt:
298
+ messages.append({"role": "system", "content": system_prompt})
299
+ messages.append({"role": "user", "content": prompt})
300
+
301
+ # `temperature or self._temperature` was wrong for 0.0, which is
302
+ # falsy: asking for a deterministic run silently got whatever the
303
+ # instance default was. Compare against None explicitly.
304
+ effective_temperature = self._temperature if temperature is None else temperature
305
+ options: dict[str, Any] = {"num_predict": max_tokens}
306
+ if effective_temperature is not None:
307
+ options["temperature"] = effective_temperature
308
+
309
+ response = await client.post(
310
+ f"{self._base_url}/api/chat",
311
+ json={
312
+ "model": self._model,
313
+ "messages": messages,
314
+ "stream": False,
315
+ "options": options,
316
+ },
317
+ timeout=120.0,
318
+ )
319
+ response.raise_for_status()
320
+ content: str = response.json()["message"]["content"]
321
+ return content
322
+
323
+ def get_graphiti_client(self) -> Any:
324
+ """Get Graphiti-compatible Ollama client."""
325
+ from graphiti_core.llm_client.config import LLMConfig
326
+
327
+ llm_config = LLMConfig(
328
+ api_key="ollama",
329
+ model=self._model,
330
+ small_model=self._small_model,
331
+ base_url=f"{self._base_url}/v1",
332
+ temperature=_graphiti_temperature(self._temperature),
333
+ )
334
+ return _make_resilient_graphiti_client(llm_config)
335
+
336
+
337
+ class OpenAILLM(LLMProviderBase):
338
+ """OpenAI LLM provider."""
339
+
340
+ def __init__(
341
+ self,
342
+ model: str | None = None,
343
+ api_key: str | None = None,
344
+ temperature: float | None = None,
345
+ small_model: str | None = None,
346
+ ) -> None:
347
+ self._api_key = api_key or os.environ.get("OPENAI_API_KEY")
348
+ self._temperature = temperature
349
+
350
+ if not self._api_key:
351
+ raise ValueError("OPENAI_API_KEY environment variable required")
352
+ if not model:
353
+ # No default on purpose. See the constants at the top of this module.
354
+ raise ValueError(
355
+ "OpenAI needs a model name and none was configured. Set "
356
+ "`llm.model` in docugraph.yaml (or DOCUGRAPH_LLM__MODEL), e.g. "
357
+ "a model id from https://platform.openai.com/docs/models."
358
+ )
359
+ self._model = model
360
+ self._small_model = small_model or model
361
+
362
+ @property
363
+ def provider_name(self) -> str:
364
+ return "openai"
365
+
366
+ @property
367
+ def model_name(self) -> str:
368
+ return self._model
369
+
370
+ async def generate(
371
+ self,
372
+ prompt: str,
373
+ system_prompt: str | None = None,
374
+ temperature: float | None = None,
375
+ max_tokens: int = 4096,
376
+ ) -> str:
377
+ """Generate using OpenAI API."""
378
+ try:
379
+ from openai import AsyncOpenAI
380
+ except ImportError as e:
381
+ raise ImportError("openai package required: pip install openai") from e
382
+
383
+ client = AsyncOpenAI(api_key=self._api_key)
384
+ messages: list[dict[str, Any]] = []
385
+ if system_prompt:
386
+ messages.append({"role": "system", "content": system_prompt})
387
+ messages.append({"role": "user", "content": prompt})
388
+
389
+ # `max_completion_tokens`, not `max_tokens`. Chat Completions has two
390
+ # output-token parameters and they are not interchangeable: the o-series
391
+ # and the GPT-5 family reject `max_tokens` outright ("Unsupported
392
+ # parameter"), while the older models accept it and ignore the newer
393
+ # name. The newer name is the one both generations understand.
394
+ request: dict[str, Any] = {
395
+ "model": self._model,
396
+ "messages": messages,
397
+ "max_completion_tokens": max_tokens,
398
+ }
399
+ # Temperature is sent only when it is set, and the same reasoning models
400
+ # reject any value other than their own fixed default -- so a 0.0 that
401
+ # nobody chose would fail the call. `temperature or self._temperature`
402
+ # additionally got 0.0 wrong, since 0.0 is falsy.
403
+ effective_temperature = self._temperature if temperature is None else temperature
404
+ if effective_temperature is not None:
405
+ request["temperature"] = effective_temperature
406
+
407
+ response = await client.chat.completions.create(**request)
408
+ content: str = response.choices[0].message.content or ""
409
+ return content
410
+
411
+ def get_graphiti_client(self) -> Any:
412
+ """Get Graphiti-compatible OpenAI client."""
413
+ from graphiti_core.llm_client.config import LLMConfig
414
+ from graphiti_core.llm_client.openai_client import OpenAIClient
415
+
416
+ llm_config = LLMConfig(
417
+ api_key=self._api_key,
418
+ model=self._model,
419
+ small_model=self._small_model,
420
+ temperature=_graphiti_temperature(self._temperature),
421
+ )
422
+ return OpenAIClient(config=llm_config)
423
+
424
+
425
+ class AnthropicLLM(LLMProviderBase):
426
+ """Anthropic LLM provider."""
427
+
428
+ def __init__(
429
+ self,
430
+ model: str | None = None,
431
+ api_key: str | None = None,
432
+ temperature: float | None = None,
433
+ small_model: str | None = None,
434
+ ) -> None:
435
+ self._api_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
436
+ self._temperature = temperature
437
+
438
+ if not self._api_key:
439
+ raise ValueError("ANTHROPIC_API_KEY environment variable required")
440
+ if not model:
441
+ # No default on purpose; see the constants at the top of this module.
442
+ raise ValueError(
443
+ "Anthropic needs a model name and none was configured. Set "
444
+ "`llm.model` in docugraph.yaml (or DOCUGRAPH_LLM__MODEL), e.g. "
445
+ "a model id from https://docs.anthropic.com/en/docs/about-claude/models."
446
+ )
447
+ self._model = model
448
+ self._small_model = small_model or model
449
+
450
+ @property
451
+ def provider_name(self) -> str:
452
+ return "anthropic"
453
+
454
+ @property
455
+ def model_name(self) -> str:
456
+ return self._model
457
+
458
+ async def generate(
459
+ self,
460
+ prompt: str,
461
+ system_prompt: str | None = None,
462
+ temperature: float | None = None, # noqa: ARG002 -- LLMProviderBase declares it
463
+ max_tokens: int = 4096,
464
+ ) -> str:
465
+ """Generate using Anthropic API."""
466
+ try:
467
+ from anthropic import AsyncAnthropic
468
+ from anthropic.types import TextBlock
469
+ except ImportError as e:
470
+ raise ImportError("anthropic package required: pip install anthropic") from e
471
+
472
+ client = AsyncAnthropic(api_key=self._api_key)
473
+ # `max_tokens` is required here, and is the only optional-looking
474
+ # parameter sent. `temperature` is not sent, and must not be added back.
475
+ # The 1.x SDK removed it from Messages.create's signature and rejects it
476
+ # before the request is built -- `TypeError: AsyncMessages.create() got
477
+ # an unexpected keyword argument 'temperature'` -- so passing it made
478
+ # every Anthropic call fail at runtime, whatever the type checker said.
479
+ # The argument stays in the signature because LLMProviderBase declares it
480
+ # and the OpenAI and Ollama providers do honour it; on this provider it is
481
+ # inert, which is a difference worth knowing rather than one to paper
482
+ # over by smuggling it through `extra_body`.
483
+ #
484
+ # `system` is omitted rather than sent as "", which is a real difference:
485
+ # an empty string is a system prompt saying nothing, and it is carried in
486
+ # the request whether or not the caller wanted one.
487
+ request: dict[str, Any] = {
488
+ "model": self._model,
489
+ "max_tokens": max_tokens,
490
+ "messages": [{"role": "user", "content": prompt}],
491
+ }
492
+ if system_prompt:
493
+ request["system"] = system_prompt
494
+
495
+ response = await client.messages.create(**request)
496
+ # `content` is a union of block types and only TextBlock carries `.text`.
497
+ # Reading `content[0].text` assumed the first block was text, which stops
498
+ # being true as soon as a reply leads with a thinking or tool-use block --
499
+ # and then the caller got an AttributeError naming no provider and no
500
+ # model. Collect the text blocks instead, and if there are none, say what
501
+ # came back rather than returning an empty string that reads as success.
502
+ text = "".join(block.text for block in response.content if isinstance(block, TextBlock))
503
+ if not text:
504
+ kinds = ", ".join(sorted({type(block).__name__ for block in response.content}))
505
+ raise RuntimeError(
506
+ f"Anthropic returned no text for model {self._model!r}; "
507
+ f"content blocks were: {kinds or 'none'}."
508
+ )
509
+ return text
510
+
511
+ def get_graphiti_client(self) -> Any:
512
+ """Get Graphiti-compatible Anthropic client."""
513
+ from graphiti_core.llm_client.anthropic_client import AnthropicClient
514
+ from graphiti_core.llm_client.config import LLMConfig
515
+
516
+ llm_config = LLMConfig(
517
+ api_key=self._api_key,
518
+ model=self._model,
519
+ small_model=self._small_model,
520
+ temperature=_graphiti_temperature(self._temperature),
521
+ )
522
+ return AnthropicClient(config=llm_config)
523
+
524
+
525
+ def is_ollama_available(base_url: str = OLLAMA_DEFAULT_BASE_URL) -> bool:
526
+ """Check if Ollama is running locally.
527
+
528
+ Args:
529
+ base_url: Ollama server URL
530
+
531
+ Returns:
532
+ True if Ollama is available
533
+ """
534
+ try:
535
+ response = httpx.get(f"{base_url}/api/tags", timeout=2.0)
536
+ return response.status_code == 200
537
+ except Exception:
538
+ return False
539
+
540
+
541
+ def get_available_ollama_models(base_url: str = OLLAMA_DEFAULT_BASE_URL) -> list[str]:
542
+ """Get list of available Ollama models.
543
+
544
+ Args:
545
+ base_url: Ollama server URL
546
+
547
+ Returns:
548
+ List of model names
549
+ """
550
+ try:
551
+ response = httpx.get(f"{base_url}/api/tags", timeout=5.0)
552
+ if response.status_code == 200:
553
+ data = response.json()
554
+ return [model["name"] for model in data.get("models", [])]
555
+ except Exception: # nosec B110 -- Ollama may be down; empty list is the answer
556
+ pass
557
+ return []
558
+
559
+
560
+ class LLMUnavailableError(RuntimeError):
561
+ """No LLM can serve the requested model.
562
+
563
+ Raised for both "no provider is set up" and "the provider is reachable but
564
+ does not have the model pulled". The second case is the reason this exists:
565
+ an Ollama server that is running without the configured model passes every
566
+ reachability check, then fails deep inside Graphiti with a provider-specific
567
+ 404 traceback. The graph tools claim to degrade gracefully; this is what
568
+ makes that true.
569
+
570
+ Subclasses RuntimeError so existing ``except RuntimeError`` handlers keep
571
+ catching it.
572
+ """
573
+
574
+
575
+ def _model_is_available(wanted: str, available: list[str]) -> bool:
576
+ """Whether `wanted` names a model the Ollama server actually has.
577
+
578
+ Ollama tags its models, so a configured ``llama3.2`` is stored as
579
+ ``llama3.2:latest``. Compare on the untagged name, but only when the
580
+ request is itself untagged -- an explicit ``llama3.2:7b`` must match
581
+ exactly, or we would send a request for a tag that is not there.
582
+ """
583
+ for name in available:
584
+ if name == wanted:
585
+ return True
586
+ if ":" not in wanted and name.split(":", 1)[0] == wanted:
587
+ return True
588
+ return False
589
+
590
+
591
+ def _diagnose_ollama(wanted_model: str, base_url: str) -> tuple[str | None, list[str]]:
592
+ """Why Ollama cannot serve `wanted_model`, plus what it does have.
593
+
594
+ Returns ``(None, installed)`` when Ollama can serve the request.
595
+ """
596
+ if not is_ollama_available(base_url):
597
+ return f"Ollama is not reachable at {base_url}", []
598
+
599
+ available = get_available_ollama_models(base_url)
600
+ if not available:
601
+ return f"Ollama at {base_url} is running but reports no models installed", []
602
+
603
+ if not _model_is_available(wanted_model, available):
604
+ return (
605
+ f"Ollama at {base_url} is running but does not have the model "
606
+ f"'{wanted_model}' (installed: {', '.join(available[:5])})",
607
+ available,
608
+ )
609
+ return None, available
610
+
611
+
612
+ # --- Cloud models: none by default, and checked against the provider's list ---
613
+
614
+ OPENAI_MODELS_URL = "https://api.openai.com/v1/models"
615
+ ANTHROPIC_MODELS_URL = "https://api.anthropic.com/v1/models"
616
+ # Anthropic requires an explicit API version on every request; there is no
617
+ # unversioned form. Pinned here rather than floated, because the shape of the
618
+ # response this reads is part of the version.
619
+ ANTHROPIC_VERSION = "2023-06-01"
620
+
621
+ # Where a user sets the model, so the error can name the exact variable rather
622
+ # than describing where it lives.
623
+ _MODEL_SETTING = {
624
+ LLMProvider.OPENAI: "DOCUGRAPH_LLM__MODEL",
625
+ LLMProvider.ANTHROPIC: "DOCUGRAPH_LLM__MODEL",
626
+ }
627
+ _MODEL_DOCS = {
628
+ LLMProvider.OPENAI: "https://platform.openai.com/docs/models",
629
+ LLMProvider.ANTHROPIC: "https://docs.anthropic.com/en/docs/about-claude/models",
630
+ }
631
+
632
+ # One verification per (provider, model) per process. Model catalogs change on
633
+ # the provider's schedule and not this process's, and the MCP server is
634
+ # long-lived, so without this every graph call would spend a round trip asking
635
+ # a question whose answer cannot have changed. Keyed without the API key, which
636
+ # is deliberately not held anywhere but the client: a process reads its keys
637
+ # once, so a second key for the same provider is not a case that arises.
638
+ _MODEL_LIST_CACHE: dict[tuple[str, str], list[str]] = {}
639
+
640
+
641
+ def _list_openai_models(api_key: str) -> list[str]:
642
+ """Every model id OpenAI will serve this key."""
643
+ response = httpx.get(
644
+ OPENAI_MODELS_URL,
645
+ headers={"Authorization": f"Bearer {api_key}"},
646
+ timeout=10.0,
647
+ )
648
+ response.raise_for_status()
649
+ data = response.json().get("data", [])
650
+ return [str(entry["id"]) for entry in data]
651
+
652
+
653
+ def _list_anthropic_models(api_key: str) -> list[str]:
654
+ """Every model id Anthropic will serve this key.
655
+
656
+ Unlike OpenAI's, this endpoint is paginated, so a single GET returns the
657
+ first page and quietly omits the rest -- which would report a model the
658
+ account can plainly use as "not found".
659
+ """
660
+ headers = {"x-api-key": api_key, "anthropic-version": ANTHROPIC_VERSION}
661
+ ids: list[str] = []
662
+ after: str | None = None
663
+ # Bounded: the catalog is a few pages, and a server that keeps saying
664
+ # `has_more` must not spin here.
665
+ for _ in range(10):
666
+ params: dict[str, Any] = {"limit": 100}
667
+ if after:
668
+ params["after_id"] = after
669
+ response = httpx.get(ANTHROPIC_MODELS_URL, headers=headers, params=params, timeout=10.0)
670
+ response.raise_for_status()
671
+ payload = response.json()
672
+ ids += [str(entry["id"]) for entry in payload.get("data", [])]
673
+ after = payload.get("last_id")
674
+ if not payload.get("has_more") or not after:
675
+ break
676
+ return ids
677
+
678
+
679
+ def _cloud_model_problem(provider: LLMProvider, model: str | None, api_key: str) -> str | None:
680
+ """Why `provider` cannot serve `model`, or None if it can.
681
+
682
+ Two questions, in order: is a model named at all, and does the provider
683
+ still offer it. The second is the one worth a network call -- a model id is
684
+ the one piece of configuration whose validity expires without anything in
685
+ this repository changing, and the failure it produces at call time is a 404
686
+ from inside Graphiti that names neither the config nor the file.
687
+ """
688
+ setting = _MODEL_SETTING[provider]
689
+ if not model or model == "auto":
690
+ return (
691
+ f"no model is configured for {provider.value}: cloud providers have no "
692
+ f"default here, because a name compiled into this package is retired on "
693
+ f"the provider's schedule and then every call fails with a 404 that "
694
+ f"names no config. Set {setting} to a model id from {_MODEL_DOCS[provider]}. "
695
+ f"Leave it unset to keep using Ollama."
696
+ )
697
+
698
+ cached = _MODEL_LIST_CACHE.get((provider.value, model))
699
+ if cached is None:
700
+ try:
701
+ cached = (
702
+ _list_openai_models(api_key)
703
+ if provider == LLMProvider.OPENAI
704
+ else _list_anthropic_models(api_key)
705
+ )
706
+ except httpx.HTTPStatusError as e:
707
+ status = e.response.status_code
708
+ if status in (401, 403):
709
+ return f"the {provider.value} API rejected the key ({status})"
710
+ return (
711
+ f"could not list {provider.value} models to check '{model}' "
712
+ f"(HTTP {status} from {OPENAI_MODELS_URL if provider == LLMProvider.OPENAI else ANTHROPIC_MODELS_URL})"
713
+ )
714
+ except httpx.HTTPError as e:
715
+ return (
716
+ f"could not reach the {provider.value} model list to check '{model}' "
717
+ f"({e.__class__.__name__}); the model is unverified, not confirmed"
718
+ )
719
+ _MODEL_LIST_CACHE[(provider.value, model)] = cached
720
+
721
+ if model in cached:
722
+ return None
723
+ shown = ", ".join(cached[:10]) or "none"
724
+ return (
725
+ f"{provider.value} does not list a model named '{model}' "
726
+ f"(available: {shown}{', ...' if len(cached) > 10 else ''})"
727
+ )
728
+
729
+
730
+ def _provider_problem(provider: LLMProvider, model: str | None) -> tuple[str | None, list[str]]:
731
+ """Why `provider` cannot serve `model` right now, plus Ollama's model list."""
732
+ if provider == LLMProvider.OLLAMA:
733
+ return _diagnose_ollama(model or OLLAMA_DEFAULT_MODEL, OLLAMA_DEFAULT_BASE_URL)
734
+ if provider == LLMProvider.OPENAI:
735
+ api_key = os.environ.get("OPENAI_API_KEY")
736
+ if not api_key:
737
+ return "OPENAI_API_KEY is not set", []
738
+ return _cloud_model_problem(provider, model, api_key), []
739
+ if provider == LLMProvider.ANTHROPIC:
740
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
741
+ if not api_key:
742
+ return "ANTHROPIC_API_KEY is not set", []
743
+ return _cloud_model_problem(provider, model, api_key), []
744
+ return f"Unsupported LLM provider: {provider}", []
745
+
746
+
747
+ def _unavailable_error(
748
+ problems: list[str],
749
+ model: str | None,
750
+ ollama_models: list[str],
751
+ ) -> LLMUnavailableError:
752
+ """Build the actionable error for an unusable LLM configuration."""
753
+ lines = [
754
+ "The knowledge-graph tools need an LLM, and none is available.",
755
+ "",
756
+ "Why not:",
757
+ ]
758
+ lines += [f" - {problem}" for problem in problems]
759
+ lines += [
760
+ "",
761
+ "To fix, pick one:",
762
+ f" ollama pull {model or OLLAMA_DEFAULT_MODEL}",
763
+ " export OPENAI_API_KEY=sk-... # and set DOCUGRAPH_LLM__MODEL",
764
+ " export ANTHROPIC_API_KEY=sk-ant-... # and set DOCUGRAPH_LLM__MODEL",
765
+ ]
766
+ if ollama_models:
767
+ lines += [
768
+ "",
769
+ "Ollama already has a model you could use instead:",
770
+ f" DOCUGRAPH_LLM__MODEL={ollama_models[0]}",
771
+ ]
772
+ lines += [
773
+ "",
774
+ "Vector and keyword search need no LLM: `docugraph search`, `search_docs` "
775
+ "and `hybrid_search` work as they are.",
776
+ ]
777
+ return LLMUnavailableError("\n".join(lines))
778
+
779
+
780
+ def get_llm_provider(
781
+ provider: LLMProvider | None = None,
782
+ model: str | None = None,
783
+ ) -> LLMProviderBase:
784
+ """Get an LLM provider instance, validating that it can actually serve.
785
+
786
+ Auto-detects the best available provider if not specified.
787
+ Priority: Ollama (local) -> OpenAI -> Anthropic
788
+
789
+ Auto-detection checks the *model*, not just the service, so an Ollama
790
+ server that is running without the configured model falls through to a
791
+ cloud key instead of being selected and then failing at call time.
792
+
793
+ Args:
794
+ provider: Specific provider to use, or None for auto-detection
795
+ model: Specific model to use, or None for provider default
796
+
797
+ Returns:
798
+ LLM provider instance
799
+
800
+ Raises:
801
+ LLMUnavailableError: If the selected provider cannot serve the
802
+ requested model -- unreachable, missing model, or missing key.
803
+ """
804
+ config = get_config()
805
+
806
+ # Use config if not specified
807
+ if provider is None:
808
+ provider = config.llm.provider
809
+
810
+ # Get model from config if not specified. "auto" is the sentinel for "the
811
+ # provider's own default" and resolves to None, which a cloud provider now
812
+ # reports as a missing setting rather than papering over with a hard-coded
813
+ # name.
814
+ if model is None or model == "auto":
815
+ model = config.llm.model if config.llm.model != "auto" else None
816
+
817
+ temperature = config.llm.temperature
818
+ # Graphiti runs a second, cheaper model for summarization and edge dedup.
819
+ # Unset means "the same as the main model", resolved per provider below --
820
+ # never a name compiled in here.
821
+ small_model = config.llm.small_model
822
+
823
+ if provider == LLMProvider.AUTO:
824
+ candidates = (LLMProvider.OLLAMA, LLMProvider.OPENAI, LLMProvider.ANTHROPIC)
825
+ problems: list[str] = []
826
+ ollama_models: list[str] = []
827
+ for candidate in candidates:
828
+ problem, models = _provider_problem(candidate, model)
829
+ if problem is None:
830
+ provider = candidate
831
+ break
832
+ problems.append(problem)
833
+ ollama_models = ollama_models or models
834
+ else:
835
+ raise _unavailable_error(problems, model, ollama_models)
836
+ else:
837
+ problem, ollama_models = _provider_problem(provider, model)
838
+ if problem is not None:
839
+ raise _unavailable_error([problem], model, ollama_models)
840
+
841
+ # Create provider instance. The cloud branches pass `model` through
842
+ # unchanged, None included: the constructors are the single place that
843
+ # decides what a missing model means, so a fallback here could not disagree
844
+ # with the preflight above.
845
+ if provider == LLMProvider.OLLAMA:
846
+ return OllamaLLM(
847
+ model=model or OLLAMA_DEFAULT_MODEL,
848
+ temperature=temperature,
849
+ small_model=small_model,
850
+ )
851
+ elif provider == LLMProvider.OPENAI:
852
+ return OpenAILLM(
853
+ model=model,
854
+ temperature=temperature,
855
+ small_model=small_model,
856
+ )
857
+ elif provider == LLMProvider.ANTHROPIC:
858
+ return AnthropicLLM(
859
+ model=model,
860
+ temperature=temperature,
861
+ small_model=small_model,
862
+ )
863
+ else:
864
+ raise ValueError(f"Unsupported LLM provider: {provider}")
865
+
866
+
867
+ def get_llm_client(
868
+ provider: LLMProvider | None = None,
869
+ model: str | None = None,
870
+ ) -> LLMProviderBase:
871
+ """Alias for get_llm_provider for backward compatibility."""
872
+ return get_llm_provider(provider=provider, model=model)
873
+
874
+
875
+ def get_graphiti_llm_client(
876
+ provider: LLMProvider | None = None,
877
+ model: str | None = None,
878
+ ) -> Any:
879
+ """Get a Graphiti-compatible LLM client.
880
+
881
+ This returns the native Graphiti client format for use with
882
+ Graphiti's knowledge graph operations.
883
+
884
+ Args:
885
+ provider: Specific provider to use, or None for auto-detection
886
+ model: Specific model to use, or None for provider default
887
+
888
+ Returns:
889
+ Graphiti-compatible LLM client
890
+ """
891
+ llm = get_llm_provider(provider=provider, model=model)
892
+ return llm.get_graphiti_client()