contextos-memory-runtime 1.0.0rc2__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.
- contextos/__init__.py +3 -0
- contextos/__main__.py +6 -0
- contextos/api/__init__.py +1 -0
- contextos/api/routes/__init__.py +1 -0
- contextos/api/routes/desktop.py +322 -0
- contextos/api/routes/ingest.py +17 -0
- contextos/api/routes/memories.py +84 -0
- contextos/api/routes/models.py +81 -0
- contextos/api/routes/retrieval.py +89 -0
- contextos/api/routes/system.py +216 -0
- contextos/api/server.py +195 -0
- contextos/benchmarks/__init__.py +1 -0
- contextos/benchmarks/compilation.py +245 -0
- contextos/benchmarks/connectors.py +423 -0
- contextos/benchmarks/explainability.py +103 -0
- contextos/benchmarks/final.py +406 -0
- contextos/benchmarks/graph.py +310 -0
- contextos/benchmarks/graph_adversarial.py +525 -0
- contextos/benchmarks/mcp.py +324 -0
- contextos/benchmarks/model_routing.py +203 -0
- contextos/benchmarks/optimization.py +305 -0
- contextos/benchmarks/rescue_integration.py +127 -0
- contextos/benchmarks/retrieval.py +266 -0
- contextos/benchmarks/temporal.py +377 -0
- contextos/benchmarks/temporal_hotpath.py +76 -0
- contextos/benchmarks/terminal.py +62 -0
- contextos/cli/__init__.py +1 -0
- contextos/cli/app.py +932 -0
- contextos/cli/dashboard.py +174 -0
- contextos/cli/formatters.py +299 -0
- contextos/config/__init__.py +1 -0
- contextos/config/settings.py +160 -0
- contextos/connectors/__init__.py +6 -0
- contextos/connectors/fake.py +11 -0
- contextos/connectors/json_import.py +125 -0
- contextos/connectors/local_files.py +102 -0
- contextos/connectors/manager.py +293 -0
- contextos/connectors/models.py +62 -0
- contextos/connectors/protocols.py +11 -0
- contextos/core/__init__.py +103 -0
- contextos/core/enums.py +489 -0
- contextos/core/exceptions.py +293 -0
- contextos/core/models.py +1147 -0
- contextos/core/protocols.py +549 -0
- contextos/daemon/__init__.py +1 -0
- contextos/daemon/manager.py +510 -0
- contextos/daemon/state.py +127 -0
- contextos/daemon/wiring.py +296 -0
- contextos/demo.py +217 -0
- contextos/embedding/__init__.py +1 -0
- contextos/embedding/deterministic.py +76 -0
- contextos/embedding/sentence_transformers.py +80 -0
- contextos/mcp/__init__.py +5 -0
- contextos/mcp/server.py +269 -0
- contextos/providers/__init__.py +13 -0
- contextos/providers/fake.py +217 -0
- contextos/providers/ollama.py +297 -0
- contextos/providers/openai_compatible.py +337 -0
- contextos/services/__init__.py +1 -0
- contextos/services/compilation.py +535 -0
- contextos/services/explainability.py +553 -0
- contextos/services/extraction.py +311 -0
- contextos/services/graph.py +524 -0
- contextos/services/graph_retrieval.py +143 -0
- contextos/services/ingestion.py +143 -0
- contextos/services/inspection.py +174 -0
- contextos/services/memory.py +291 -0
- contextos/services/model_service.py +409 -0
- contextos/services/optimization.py +426 -0
- contextos/services/privacy.py +331 -0
- contextos/services/retrieval.py +302 -0
- contextos/services/retrieval_index.py +88 -0
- contextos/services/router.py +302 -0
- contextos/services/secret_scanner.py +207 -0
- contextos/services/telemetry_query.py +102 -0
- contextos/services/temporal.py +500 -0
- contextos/services/token_counter.py +222 -0
- contextos/storage/__init__.py +1 -0
- contextos/storage/connector_repo.py +67 -0
- contextos/storage/database.py +497 -0
- contextos/storage/event_repo.py +137 -0
- contextos/storage/graph_repo.py +228 -0
- contextos/storage/lexical/__init__.py +1 -0
- contextos/storage/lexical/bm25.py +134 -0
- contextos/storage/memory_repo.py +589 -0
- contextos/storage/relation_repo.py +80 -0
- contextos/storage/telemetry_repo.py +481 -0
- contextos/storage/vector/__init__.py +1 -0
- contextos/storage/vector/in_memory.py +162 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/METADATA +143 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/RECORD +93 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/WHEEL +4 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Ollama provider adapter for local inference."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from contextos.core.enums import ModelFinishReason, TokenMeasurementSource
|
|
13
|
+
from contextos.core.exceptions import (
|
|
14
|
+
MalformedProviderResponseError,
|
|
15
|
+
ModelUnavailableError,
|
|
16
|
+
ProviderAuthenticationError,
|
|
17
|
+
ProviderRateLimitError,
|
|
18
|
+
ProviderTimeoutError,
|
|
19
|
+
ProviderUnavailableError,
|
|
20
|
+
)
|
|
21
|
+
from contextos.core.models import ModelCapabilities, ModelRequest, ModelResponse
|
|
22
|
+
from contextos.services.token_counter import get_token_counter_for_model
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OllamaProvider:
|
|
28
|
+
"""Async adapter for Ollama local inference API."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
base_url: str = "http://127.0.0.1:11434",
|
|
33
|
+
default_model: str = "llama3.2",
|
|
34
|
+
timeout_seconds: float = 30.0,
|
|
35
|
+
health_cache_ttl_seconds: float = 30.0,
|
|
36
|
+
client: httpx.AsyncClient | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._provider_id = "ollama"
|
|
39
|
+
self._base_url = base_url.rstrip("/")
|
|
40
|
+
self._default_model = default_model
|
|
41
|
+
self._timeout_seconds = timeout_seconds
|
|
42
|
+
self._health_ttl = health_cache_ttl_seconds
|
|
43
|
+
self._client = client
|
|
44
|
+
|
|
45
|
+
# Health caching to prevent expensive O(N) network calls per request
|
|
46
|
+
self._last_health: bool = False
|
|
47
|
+
self._last_health_ts: float = 0.0
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def provider_id(self) -> str:
|
|
51
|
+
return self._provider_id
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def is_local(self) -> bool:
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
def _get_client(self, timeout: float | None = None) -> httpx.AsyncClient:
|
|
58
|
+
if self._client is not None:
|
|
59
|
+
return self._client
|
|
60
|
+
t = timeout or self._timeout_seconds
|
|
61
|
+
return httpx.AsyncClient(base_url=self._base_url, timeout=t)
|
|
62
|
+
|
|
63
|
+
async def health(self) -> bool:
|
|
64
|
+
"""Bounded, cached health check against Ollama /api/tags."""
|
|
65
|
+
now = time.time()
|
|
66
|
+
if (now - self._last_health_ts) < self._health_ttl:
|
|
67
|
+
return self._last_health
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
client = self._get_client(timeout=0.5)
|
|
71
|
+
if self._client is not None:
|
|
72
|
+
resp = await client.get("/api/tags")
|
|
73
|
+
else:
|
|
74
|
+
async with client as c:
|
|
75
|
+
resp = await c.get("/api/tags")
|
|
76
|
+
self._last_health = (resp.status_code == 200)
|
|
77
|
+
except Exception:
|
|
78
|
+
self._last_health = False
|
|
79
|
+
|
|
80
|
+
self._last_health_ts = now
|
|
81
|
+
return self._last_health
|
|
82
|
+
|
|
83
|
+
async def list_models(self) -> list[ModelCapabilities]:
|
|
84
|
+
"""Fetch model tags from Ollama."""
|
|
85
|
+
if not await self.health():
|
|
86
|
+
# Provider is offline/unhealthy; return disabled default descriptor
|
|
87
|
+
return [
|
|
88
|
+
ModelCapabilities(
|
|
89
|
+
provider_id=self._provider_id,
|
|
90
|
+
model_id=self._default_model,
|
|
91
|
+
display_name=self._default_model,
|
|
92
|
+
context_window=8192,
|
|
93
|
+
max_output_tokens=2048,
|
|
94
|
+
supports_tools=True,
|
|
95
|
+
supports_json=True,
|
|
96
|
+
supports_vision=False,
|
|
97
|
+
local=True,
|
|
98
|
+
tokenizer_family="llama",
|
|
99
|
+
enabled=False,
|
|
100
|
+
)
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
client = self._get_client(timeout=5.0)
|
|
105
|
+
if self._client is not None:
|
|
106
|
+
resp = await client.get("/api/tags")
|
|
107
|
+
else:
|
|
108
|
+
async with client as c:
|
|
109
|
+
resp = await c.get("/api/tags")
|
|
110
|
+
|
|
111
|
+
if resp.status_code != 200:
|
|
112
|
+
raise ProviderUnavailableError(
|
|
113
|
+
self._provider_id, f"HTTP {resp.status_code} from /api/tags"
|
|
114
|
+
)
|
|
115
|
+
data = resp.json()
|
|
116
|
+
except httpx.TimeoutException as exc:
|
|
117
|
+
raise ProviderTimeoutError(self._provider_id, 5.0) from exc
|
|
118
|
+
except (httpx.ConnectError, httpx.NetworkError) as exc:
|
|
119
|
+
raise ProviderUnavailableError(self._provider_id, "Could not connect to Ollama") from exc
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
if isinstance(exc, (ProviderTimeoutError, ProviderUnavailableError)):
|
|
122
|
+
raise
|
|
123
|
+
raise ProviderUnavailableError(self._provider_id, "Unexpected failure listing models") from exc
|
|
124
|
+
|
|
125
|
+
raw_models = data.get("models", [])
|
|
126
|
+
capabilities: list[ModelCapabilities] = []
|
|
127
|
+
for m in raw_models:
|
|
128
|
+
name = m.get("name", "")
|
|
129
|
+
if not name:
|
|
130
|
+
continue
|
|
131
|
+
family = "qwen" if "qwen" in name.lower() else "llama"
|
|
132
|
+
capabilities.append(
|
|
133
|
+
ModelCapabilities(
|
|
134
|
+
provider_id=self._provider_id,
|
|
135
|
+
model_id=name,
|
|
136
|
+
display_name=name,
|
|
137
|
+
context_window=8192,
|
|
138
|
+
max_output_tokens=2048,
|
|
139
|
+
supports_tools=True,
|
|
140
|
+
supports_json=True,
|
|
141
|
+
supports_vision=False,
|
|
142
|
+
local=True,
|
|
143
|
+
tokenizer_family=family,
|
|
144
|
+
enabled=True,
|
|
145
|
+
metadata={"details": m.get("details", {})},
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if not capabilities:
|
|
150
|
+
# Fallback entry if no models are downloaded yet
|
|
151
|
+
capabilities.append(
|
|
152
|
+
ModelCapabilities(
|
|
153
|
+
provider_id=self._provider_id,
|
|
154
|
+
model_id=self._default_model,
|
|
155
|
+
display_name=self._default_model,
|
|
156
|
+
context_window=8192,
|
|
157
|
+
max_output_tokens=2048,
|
|
158
|
+
supports_tools=True,
|
|
159
|
+
supports_json=True,
|
|
160
|
+
supports_vision=False,
|
|
161
|
+
local=True,
|
|
162
|
+
tokenizer_family="llama",
|
|
163
|
+
enabled=True,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return capabilities
|
|
168
|
+
|
|
169
|
+
def count_tokens(self, text: str, model: str) -> int:
|
|
170
|
+
family = "qwen" if "qwen" in model.lower() else "cl100k_base"
|
|
171
|
+
counter = get_token_counter_for_model(model, family)
|
|
172
|
+
return counter.count(text)
|
|
173
|
+
|
|
174
|
+
async def generate(self, request: ModelRequest) -> ModelResponse:
|
|
175
|
+
"""Send chat generation request to Ollama /api/chat."""
|
|
176
|
+
model_name = request.model or self._default_model
|
|
177
|
+
started = time.perf_counter()
|
|
178
|
+
|
|
179
|
+
# Build messages payload
|
|
180
|
+
messages: list[dict[str, str]] = []
|
|
181
|
+
system_content_parts: list[str] = []
|
|
182
|
+
if request.system_prompt:
|
|
183
|
+
system_content_parts.append(request.system_prompt)
|
|
184
|
+
if request.compiled_context and request.compiled_context.context_text:
|
|
185
|
+
system_content_parts.append(
|
|
186
|
+
f"### Context Information:\n{request.compiled_context.context_text}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if system_content_parts:
|
|
190
|
+
messages.append({"role": "system", "content": "\n\n".join(system_content_parts)})
|
|
191
|
+
|
|
192
|
+
messages.append({"role": "user", "content": request.user_prompt})
|
|
193
|
+
|
|
194
|
+
payload = {
|
|
195
|
+
"model": model_name,
|
|
196
|
+
"messages": messages,
|
|
197
|
+
"stream": False,
|
|
198
|
+
"options": {
|
|
199
|
+
"temperature": request.temperature,
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
if request.max_output_tokens:
|
|
203
|
+
payload["options"]["num_predict"] = request.max_output_tokens
|
|
204
|
+
|
|
205
|
+
timeout_val = request.timeout_seconds or self._timeout_seconds
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
client = self._get_client(timeout=timeout_val)
|
|
209
|
+
if self._client is not None:
|
|
210
|
+
resp = await client.post("/api/chat", json=payload)
|
|
211
|
+
else:
|
|
212
|
+
async with client as c:
|
|
213
|
+
resp = await c.post("/api/chat", json=payload)
|
|
214
|
+
|
|
215
|
+
if resp.status_code == 404:
|
|
216
|
+
raise ModelUnavailableError(model_name, self._provider_id)
|
|
217
|
+
elif resp.status_code == 401 or resp.status_code == 403:
|
|
218
|
+
raise ProviderAuthenticationError(self._provider_id)
|
|
219
|
+
elif resp.status_code == 429:
|
|
220
|
+
raise ProviderRateLimitError(self._provider_id)
|
|
221
|
+
elif resp.status_code >= 500:
|
|
222
|
+
raise ProviderUnavailableError(
|
|
223
|
+
self._provider_id, f"Server error HTTP {resp.status_code}"
|
|
224
|
+
)
|
|
225
|
+
elif resp.status_code != 200:
|
|
226
|
+
raise MalformedProviderResponseError(
|
|
227
|
+
self._provider_id, f"Unexpected HTTP status {resp.status_code}"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
data = resp.json()
|
|
232
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
233
|
+
raise MalformedProviderResponseError(
|
|
234
|
+
self._provider_id, "Response body was not valid JSON"
|
|
235
|
+
) from exc
|
|
236
|
+
except httpx.TimeoutException as exc:
|
|
237
|
+
raise ProviderTimeoutError(self._provider_id, timeout_val) from exc
|
|
238
|
+
except (httpx.ConnectError, httpx.NetworkError) as exc:
|
|
239
|
+
raise ProviderUnavailableError(self._provider_id, "Could not connect to Ollama") from exc
|
|
240
|
+
except Exception as exc:
|
|
241
|
+
if isinstance(
|
|
242
|
+
exc,
|
|
243
|
+
(
|
|
244
|
+
ModelUnavailableError,
|
|
245
|
+
ProviderAuthenticationError,
|
|
246
|
+
ProviderRateLimitError,
|
|
247
|
+
ProviderTimeoutError,
|
|
248
|
+
ProviderUnavailableError,
|
|
249
|
+
MalformedProviderResponseError,
|
|
250
|
+
),
|
|
251
|
+
):
|
|
252
|
+
raise
|
|
253
|
+
raise ProviderUnavailableError(self._provider_id, "Ollama request failed") from exc
|
|
254
|
+
|
|
255
|
+
message = data.get("message", {})
|
|
256
|
+
text = message.get("content", "")
|
|
257
|
+
if not text and "response" in data:
|
|
258
|
+
text = data["response"]
|
|
259
|
+
|
|
260
|
+
if not text and not data.get("done", False):
|
|
261
|
+
raise MalformedProviderResponseError(self._provider_id, "Empty response text")
|
|
262
|
+
|
|
263
|
+
prompt_eval_count = data.get("prompt_eval_count", 0)
|
|
264
|
+
eval_count = data.get("eval_count", 0)
|
|
265
|
+
|
|
266
|
+
latency_ms = (time.perf_counter() - started) * 1000.0
|
|
267
|
+
|
|
268
|
+
if prompt_eval_count > 0:
|
|
269
|
+
source = TokenMeasurementSource.PROVIDER_REPORTED
|
|
270
|
+
in_tok = prompt_eval_count
|
|
271
|
+
out_tok = eval_count
|
|
272
|
+
tot_tok = in_tok + out_tok
|
|
273
|
+
else:
|
|
274
|
+
family = "qwen" if "qwen" in model_name.lower() else "cl100k_base"
|
|
275
|
+
counter = get_token_counter_for_model(model_name, family)
|
|
276
|
+
source = counter.measurement_source
|
|
277
|
+
in_tok = counter.count(request.user_prompt)
|
|
278
|
+
out_tok = counter.count(text)
|
|
279
|
+
tot_tok = in_tok + out_tok
|
|
280
|
+
|
|
281
|
+
finish_reason = ModelFinishReason.STOP
|
|
282
|
+
if data.get("done_reason") == "length":
|
|
283
|
+
finish_reason = ModelFinishReason.LENGTH
|
|
284
|
+
|
|
285
|
+
return ModelResponse(
|
|
286
|
+
text=text,
|
|
287
|
+
model_id=model_name,
|
|
288
|
+
provider_id=self._provider_id,
|
|
289
|
+
input_tokens=in_tok,
|
|
290
|
+
output_tokens=out_tok,
|
|
291
|
+
total_tokens=tot_tok,
|
|
292
|
+
latency_ms=latency_ms,
|
|
293
|
+
finish_reason=finish_reason,
|
|
294
|
+
token_measurement_source=source,
|
|
295
|
+
raw_usage={"prompt_eval_count": prompt_eval_count, "eval_count": eval_count},
|
|
296
|
+
request_id=f"ollama-{int(started * 1000)}",
|
|
297
|
+
)
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""OpenAI-compatible provider adapter for local and remote endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from contextos.core.enums import ModelFinishReason, TokenMeasurementSource
|
|
15
|
+
from contextos.core.exceptions import (
|
|
16
|
+
MalformedProviderResponseError,
|
|
17
|
+
ModelUnavailableError,
|
|
18
|
+
ProviderAuthenticationError,
|
|
19
|
+
ProviderRateLimitError,
|
|
20
|
+
ProviderTimeoutError,
|
|
21
|
+
ProviderUnavailableError,
|
|
22
|
+
)
|
|
23
|
+
from contextos.core.models import ModelCapabilities, ModelRequest, ModelResponse
|
|
24
|
+
from contextos.services.token_counter import get_token_counter_for_model
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OpenAICompatibleProvider:
|
|
30
|
+
"""Async adapter for generic OpenAI-compatible endpoints.
|
|
31
|
+
|
|
32
|
+
Supports local runtimes (vLLM, LM Studio, llama.cpp server) and remote APIs.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
provider_id: str = "openai_compatible",
|
|
38
|
+
base_url: str = "http://127.0.0.1:8000/v1",
|
|
39
|
+
api_key: str | None = None,
|
|
40
|
+
default_model: str = "default-model",
|
|
41
|
+
timeout_seconds: float = 30.0,
|
|
42
|
+
is_local: bool | None = None,
|
|
43
|
+
health_cache_ttl_seconds: float = 30.0,
|
|
44
|
+
client: httpx.AsyncClient | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._provider_id = provider_id
|
|
47
|
+
self._base_url = base_url.rstrip("/")
|
|
48
|
+
self._api_key = api_key
|
|
49
|
+
self._default_model = default_model
|
|
50
|
+
self._timeout_seconds = timeout_seconds
|
|
51
|
+
self._health_ttl = health_cache_ttl_seconds
|
|
52
|
+
self._client = client
|
|
53
|
+
|
|
54
|
+
# Infer locality if not explicitly specified
|
|
55
|
+
if is_local is not None:
|
|
56
|
+
self._is_local = is_local
|
|
57
|
+
else:
|
|
58
|
+
parsed = urlparse(self._base_url)
|
|
59
|
+
hostname = (parsed.hostname or "").lower()
|
|
60
|
+
if hostname in {"127.0.0.1", "localhost", "0.0.0.0", "::1"} or hostname.endswith(".local"):
|
|
61
|
+
self._is_local = True
|
|
62
|
+
else:
|
|
63
|
+
try:
|
|
64
|
+
ip = ipaddress.ip_address(hostname)
|
|
65
|
+
self._is_local = ip.is_private or ip.is_loopback
|
|
66
|
+
except ValueError:
|
|
67
|
+
self._is_local = False
|
|
68
|
+
|
|
69
|
+
# Health caching
|
|
70
|
+
self._last_health: bool = False
|
|
71
|
+
self._last_health_ts: float = 0.0
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def provider_id(self) -> str:
|
|
75
|
+
return self._provider_id
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def is_local(self) -> bool:
|
|
79
|
+
return self._is_local
|
|
80
|
+
|
|
81
|
+
def _get_headers(self) -> dict[str, str]:
|
|
82
|
+
headers = {"Content-Type": "application/json"}
|
|
83
|
+
if self._api_key:
|
|
84
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
85
|
+
return headers
|
|
86
|
+
|
|
87
|
+
def _get_client(self, timeout: float | None = None) -> httpx.AsyncClient:
|
|
88
|
+
if self._client is not None:
|
|
89
|
+
return self._client
|
|
90
|
+
t = timeout or self._timeout_seconds
|
|
91
|
+
return httpx.AsyncClient(base_url=self._base_url, headers=self._get_headers(), timeout=t)
|
|
92
|
+
|
|
93
|
+
async def health(self) -> bool:
|
|
94
|
+
"""Bounded, cached health check against /models endpoint."""
|
|
95
|
+
now = time.time()
|
|
96
|
+
if (now - self._last_health_ts) < self._health_ttl:
|
|
97
|
+
return self._last_health
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
client = self._get_client(timeout=0.5)
|
|
101
|
+
if self._client is not None:
|
|
102
|
+
resp = await client.get("/models", headers=self._get_headers())
|
|
103
|
+
else:
|
|
104
|
+
async with client as c:
|
|
105
|
+
resp = await c.get("/models")
|
|
106
|
+
self._last_health = (resp.status_code == 200)
|
|
107
|
+
except Exception:
|
|
108
|
+
self._last_health = False
|
|
109
|
+
|
|
110
|
+
self._last_health_ts = now
|
|
111
|
+
return self._last_health
|
|
112
|
+
|
|
113
|
+
async def list_models(self) -> list[ModelCapabilities]:
|
|
114
|
+
"""Fetch model inventory from /models."""
|
|
115
|
+
if not await self.health():
|
|
116
|
+
return [
|
|
117
|
+
ModelCapabilities(
|
|
118
|
+
provider_id=self._provider_id,
|
|
119
|
+
model_id=self._default_model,
|
|
120
|
+
display_name=self._default_model,
|
|
121
|
+
context_window=8192,
|
|
122
|
+
max_output_tokens=2048,
|
|
123
|
+
supports_tools=True,
|
|
124
|
+
supports_json=True,
|
|
125
|
+
supports_vision=False,
|
|
126
|
+
local=self._is_local,
|
|
127
|
+
tokenizer_family="cl100k_base",
|
|
128
|
+
enabled=False,
|
|
129
|
+
)
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
client = self._get_client(timeout=5.0)
|
|
134
|
+
if self._client is not None:
|
|
135
|
+
resp = await client.get("/models", headers=self._get_headers())
|
|
136
|
+
else:
|
|
137
|
+
async with client as c:
|
|
138
|
+
resp = await c.get("/models")
|
|
139
|
+
|
|
140
|
+
if resp.status_code == 401 or resp.status_code == 403:
|
|
141
|
+
raise ProviderAuthenticationError(self._provider_id)
|
|
142
|
+
if resp.status_code != 200:
|
|
143
|
+
raise ProviderUnavailableError(
|
|
144
|
+
self._provider_id, f"HTTP {resp.status_code} from /models"
|
|
145
|
+
)
|
|
146
|
+
data = resp.json()
|
|
147
|
+
except httpx.TimeoutException as exc:
|
|
148
|
+
raise ProviderTimeoutError(self._provider_id, 5.0) from exc
|
|
149
|
+
except (httpx.ConnectError, httpx.NetworkError) as exc:
|
|
150
|
+
raise ProviderUnavailableError(self._provider_id, "Could not connect to provider endpoint") from exc
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
if isinstance(exc, (ProviderAuthenticationError, ProviderTimeoutError, ProviderUnavailableError)):
|
|
153
|
+
raise
|
|
154
|
+
raise ProviderUnavailableError(self._provider_id, "Failed listing models") from exc
|
|
155
|
+
|
|
156
|
+
raw_models = data.get("data", [])
|
|
157
|
+
capabilities: list[ModelCapabilities] = []
|
|
158
|
+
for m in raw_models:
|
|
159
|
+
mid = m.get("id", "")
|
|
160
|
+
if not mid:
|
|
161
|
+
continue
|
|
162
|
+
family = "cl100k_base"
|
|
163
|
+
if "qwen" in mid.lower():
|
|
164
|
+
family = "qwen"
|
|
165
|
+
elif "claude" in mid.lower():
|
|
166
|
+
family = "claude"
|
|
167
|
+
|
|
168
|
+
capabilities.append(
|
|
169
|
+
ModelCapabilities(
|
|
170
|
+
provider_id=self._provider_id,
|
|
171
|
+
model_id=mid,
|
|
172
|
+
display_name=mid,
|
|
173
|
+
context_window=32768 if "32k" in mid.lower() else 8192,
|
|
174
|
+
max_output_tokens=2048,
|
|
175
|
+
supports_tools=True,
|
|
176
|
+
supports_json=True,
|
|
177
|
+
supports_vision="vision" in mid.lower(),
|
|
178
|
+
local=self._is_local,
|
|
179
|
+
tokenizer_family=family,
|
|
180
|
+
enabled=True,
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if not capabilities:
|
|
185
|
+
capabilities.append(
|
|
186
|
+
ModelCapabilities(
|
|
187
|
+
provider_id=self._provider_id,
|
|
188
|
+
model_id=self._default_model,
|
|
189
|
+
display_name=self._default_model,
|
|
190
|
+
context_window=8192,
|
|
191
|
+
max_output_tokens=2048,
|
|
192
|
+
supports_tools=True,
|
|
193
|
+
supports_json=True,
|
|
194
|
+
supports_vision=False,
|
|
195
|
+
local=self._is_local,
|
|
196
|
+
tokenizer_family="cl100k_base",
|
|
197
|
+
enabled=True,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
return capabilities
|
|
202
|
+
|
|
203
|
+
def count_tokens(self, text: str, model: str) -> int:
|
|
204
|
+
counter = get_token_counter_for_model(model)
|
|
205
|
+
return counter.count(text)
|
|
206
|
+
|
|
207
|
+
async def generate(self, request: ModelRequest) -> ModelResponse:
|
|
208
|
+
"""Send chat completion request to /chat/completions."""
|
|
209
|
+
model_name = request.model or self._default_model
|
|
210
|
+
started = time.perf_counter()
|
|
211
|
+
|
|
212
|
+
messages: list[dict[str, str]] = []
|
|
213
|
+
system_content_parts: list[str] = []
|
|
214
|
+
if request.system_prompt:
|
|
215
|
+
system_content_parts.append(request.system_prompt)
|
|
216
|
+
if request.compiled_context and request.compiled_context.context_text:
|
|
217
|
+
system_content_parts.append(
|
|
218
|
+
f"### Context Information:\n{request.compiled_context.context_text}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if system_content_parts:
|
|
222
|
+
messages.append({"role": "system", "content": "\n\n".join(system_content_parts)})
|
|
223
|
+
|
|
224
|
+
messages.append({"role": "user", "content": request.user_prompt})
|
|
225
|
+
|
|
226
|
+
payload = {
|
|
227
|
+
"model": model_name,
|
|
228
|
+
"messages": messages,
|
|
229
|
+
"temperature": request.temperature,
|
|
230
|
+
}
|
|
231
|
+
if request.max_output_tokens:
|
|
232
|
+
payload["max_tokens"] = request.max_output_tokens
|
|
233
|
+
|
|
234
|
+
timeout_val = request.timeout_seconds or self._timeout_seconds
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
client = self._get_client(timeout=timeout_val)
|
|
238
|
+
if self._client is not None:
|
|
239
|
+
resp = await client.post("/chat/completions", json=payload, headers=self._get_headers())
|
|
240
|
+
else:
|
|
241
|
+
async with client as c:
|
|
242
|
+
resp = await c.post("/chat/completions", json=payload)
|
|
243
|
+
|
|
244
|
+
if resp.status_code == 401 or resp.status_code == 403:
|
|
245
|
+
# NEVER leak the API key or raw Authorization header in exception message
|
|
246
|
+
raise ProviderAuthenticationError(self._provider_id, "Invalid credentials or unauthorized")
|
|
247
|
+
elif resp.status_code == 404:
|
|
248
|
+
raise ModelUnavailableError(model_name, self._provider_id)
|
|
249
|
+
elif resp.status_code == 429:
|
|
250
|
+
retry_after_str = resp.headers.get("retry-after")
|
|
251
|
+
retry_after: float | None = None
|
|
252
|
+
if retry_after_str:
|
|
253
|
+
try:
|
|
254
|
+
retry_after = float(retry_after_str)
|
|
255
|
+
except ValueError:
|
|
256
|
+
pass
|
|
257
|
+
raise ProviderRateLimitError(self._provider_id, retry_after=retry_after)
|
|
258
|
+
elif resp.status_code >= 500:
|
|
259
|
+
raise ProviderUnavailableError(
|
|
260
|
+
self._provider_id, f"Server error HTTP {resp.status_code}"
|
|
261
|
+
)
|
|
262
|
+
elif resp.status_code != 200:
|
|
263
|
+
raise MalformedProviderResponseError(
|
|
264
|
+
self._provider_id, f"Unexpected HTTP status {resp.status_code}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
data = resp.json()
|
|
269
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
270
|
+
raise MalformedProviderResponseError(
|
|
271
|
+
self._provider_id, "Response body was not valid JSON"
|
|
272
|
+
) from exc
|
|
273
|
+
except httpx.TimeoutException as exc:
|
|
274
|
+
raise ProviderTimeoutError(self._provider_id, timeout_val) from exc
|
|
275
|
+
except (httpx.ConnectError, httpx.NetworkError) as exc:
|
|
276
|
+
raise ProviderUnavailableError(self._provider_id, "Could not connect to endpoint") from exc
|
|
277
|
+
except Exception as exc:
|
|
278
|
+
if isinstance(
|
|
279
|
+
exc,
|
|
280
|
+
(
|
|
281
|
+
ModelUnavailableError,
|
|
282
|
+
ProviderAuthenticationError,
|
|
283
|
+
ProviderRateLimitError,
|
|
284
|
+
ProviderTimeoutError,
|
|
285
|
+
ProviderUnavailableError,
|
|
286
|
+
MalformedProviderResponseError,
|
|
287
|
+
),
|
|
288
|
+
):
|
|
289
|
+
raise
|
|
290
|
+
raise ProviderUnavailableError(self._provider_id, "Request failed") from exc
|
|
291
|
+
|
|
292
|
+
choices = data.get("choices", [])
|
|
293
|
+
if not choices:
|
|
294
|
+
raise MalformedProviderResponseError(self._provider_id, "Response contained no choices")
|
|
295
|
+
|
|
296
|
+
msg = choices[0].get("message", {})
|
|
297
|
+
text = msg.get("content", "")
|
|
298
|
+
finish_reason_raw = choices[0].get("finish_reason", "stop")
|
|
299
|
+
|
|
300
|
+
finish_reason = ModelFinishReason.STOP
|
|
301
|
+
if finish_reason_raw == "length":
|
|
302
|
+
finish_reason = ModelFinishReason.LENGTH
|
|
303
|
+
elif finish_reason_raw in {"content_filter", "safety"}:
|
|
304
|
+
finish_reason = ModelFinishReason.CONTENT_FILTER
|
|
305
|
+
|
|
306
|
+
usage = data.get("usage", {})
|
|
307
|
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
308
|
+
completion_tokens = usage.get("completion_tokens", 0)
|
|
309
|
+
total_tokens = usage.get("total_tokens", prompt_tokens + completion_tokens)
|
|
310
|
+
|
|
311
|
+
latency_ms = (time.perf_counter() - started) * 1000.0
|
|
312
|
+
|
|
313
|
+
if prompt_tokens > 0:
|
|
314
|
+
source = TokenMeasurementSource.PROVIDER_REPORTED
|
|
315
|
+
in_tok = prompt_tokens
|
|
316
|
+
out_tok = completion_tokens
|
|
317
|
+
tot_tok = total_tokens
|
|
318
|
+
else:
|
|
319
|
+
counter = get_token_counter_for_model(model_name)
|
|
320
|
+
source = counter.measurement_source
|
|
321
|
+
in_tok = counter.count(request.user_prompt)
|
|
322
|
+
out_tok = counter.count(text)
|
|
323
|
+
tot_tok = in_tok + out_tok
|
|
324
|
+
|
|
325
|
+
return ModelResponse(
|
|
326
|
+
text=text,
|
|
327
|
+
model_id=model_name,
|
|
328
|
+
provider_id=self._provider_id,
|
|
329
|
+
input_tokens=in_tok,
|
|
330
|
+
output_tokens=out_tok,
|
|
331
|
+
total_tokens=tot_tok,
|
|
332
|
+
latency_ms=latency_ms,
|
|
333
|
+
finish_reason=finish_reason,
|
|
334
|
+
token_measurement_source=source,
|
|
335
|
+
raw_usage=usage if usage else None,
|
|
336
|
+
request_id=data.get("id"),
|
|
337
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Services package for ContextOS."""
|