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.
- docugraph/__init__.py +24 -0
- docugraph/_version.py +28 -0
- docugraph/agents/__init__.py +27 -0
- docugraph/agents/coding_agent.py +532 -0
- docugraph/agents/tools/__init__.py +62 -0
- docugraph/agents/tools/graph_query.py +203 -0
- docugraph/agents/tools/memory_ops.py +305 -0
- docugraph/agents/tools/search_code.py +305 -0
- docugraph/agents/tools/search_docs.py +148 -0
- docugraph/core/__init__.py +34 -0
- docugraph/core/config.py +183 -0
- docugraph/core/embeddings.py +394 -0
- docugraph/core/llm.py +892 -0
- docugraph/core/models.py +145 -0
- docugraph/ingestion/__init__.py +64 -0
- docugraph/ingestion/api_docs.py +646 -0
- docugraph/ingestion/chunker.py +382 -0
- docugraph/ingestion/crawler.py +305 -0
- docugraph/ingestion/git_indexer.py +556 -0
- docugraph/ingestion/local_files.py +516 -0
- docugraph/interfaces/__init__.py +1 -0
- docugraph/interfaces/cli.py +983 -0
- docugraph/interfaces/mcp_server.py +918 -0
- docugraph/retrieval/__init__.py +54 -0
- docugraph/retrieval/context_builder.py +522 -0
- docugraph/retrieval/hybrid_search.py +534 -0
- docugraph/retrieval/reranker.py +532 -0
- docugraph/storage/__init__.py +14 -0
- docugraph/storage/graph_store.py +602 -0
- docugraph/storage/memory_store.py +529 -0
- docugraph/storage/vector_store.py +367 -0
- docugraph-0.1.0.dist-info/METADATA +354 -0
- docugraph-0.1.0.dist-info/RECORD +37 -0
- docugraph-0.1.0.dist-info/WHEEL +4 -0
- docugraph-0.1.0.dist-info/entry_points.txt +3 -0
- docugraph-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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()
|