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.
Files changed (93) hide show
  1. contextos/__init__.py +3 -0
  2. contextos/__main__.py +6 -0
  3. contextos/api/__init__.py +1 -0
  4. contextos/api/routes/__init__.py +1 -0
  5. contextos/api/routes/desktop.py +322 -0
  6. contextos/api/routes/ingest.py +17 -0
  7. contextos/api/routes/memories.py +84 -0
  8. contextos/api/routes/models.py +81 -0
  9. contextos/api/routes/retrieval.py +89 -0
  10. contextos/api/routes/system.py +216 -0
  11. contextos/api/server.py +195 -0
  12. contextos/benchmarks/__init__.py +1 -0
  13. contextos/benchmarks/compilation.py +245 -0
  14. contextos/benchmarks/connectors.py +423 -0
  15. contextos/benchmarks/explainability.py +103 -0
  16. contextos/benchmarks/final.py +406 -0
  17. contextos/benchmarks/graph.py +310 -0
  18. contextos/benchmarks/graph_adversarial.py +525 -0
  19. contextos/benchmarks/mcp.py +324 -0
  20. contextos/benchmarks/model_routing.py +203 -0
  21. contextos/benchmarks/optimization.py +305 -0
  22. contextos/benchmarks/rescue_integration.py +127 -0
  23. contextos/benchmarks/retrieval.py +266 -0
  24. contextos/benchmarks/temporal.py +377 -0
  25. contextos/benchmarks/temporal_hotpath.py +76 -0
  26. contextos/benchmarks/terminal.py +62 -0
  27. contextos/cli/__init__.py +1 -0
  28. contextos/cli/app.py +932 -0
  29. contextos/cli/dashboard.py +174 -0
  30. contextos/cli/formatters.py +299 -0
  31. contextos/config/__init__.py +1 -0
  32. contextos/config/settings.py +160 -0
  33. contextos/connectors/__init__.py +6 -0
  34. contextos/connectors/fake.py +11 -0
  35. contextos/connectors/json_import.py +125 -0
  36. contextos/connectors/local_files.py +102 -0
  37. contextos/connectors/manager.py +293 -0
  38. contextos/connectors/models.py +62 -0
  39. contextos/connectors/protocols.py +11 -0
  40. contextos/core/__init__.py +103 -0
  41. contextos/core/enums.py +489 -0
  42. contextos/core/exceptions.py +293 -0
  43. contextos/core/models.py +1147 -0
  44. contextos/core/protocols.py +549 -0
  45. contextos/daemon/__init__.py +1 -0
  46. contextos/daemon/manager.py +510 -0
  47. contextos/daemon/state.py +127 -0
  48. contextos/daemon/wiring.py +296 -0
  49. contextos/demo.py +217 -0
  50. contextos/embedding/__init__.py +1 -0
  51. contextos/embedding/deterministic.py +76 -0
  52. contextos/embedding/sentence_transformers.py +80 -0
  53. contextos/mcp/__init__.py +5 -0
  54. contextos/mcp/server.py +269 -0
  55. contextos/providers/__init__.py +13 -0
  56. contextos/providers/fake.py +217 -0
  57. contextos/providers/ollama.py +297 -0
  58. contextos/providers/openai_compatible.py +337 -0
  59. contextos/services/__init__.py +1 -0
  60. contextos/services/compilation.py +535 -0
  61. contextos/services/explainability.py +553 -0
  62. contextos/services/extraction.py +311 -0
  63. contextos/services/graph.py +524 -0
  64. contextos/services/graph_retrieval.py +143 -0
  65. contextos/services/ingestion.py +143 -0
  66. contextos/services/inspection.py +174 -0
  67. contextos/services/memory.py +291 -0
  68. contextos/services/model_service.py +409 -0
  69. contextos/services/optimization.py +426 -0
  70. contextos/services/privacy.py +331 -0
  71. contextos/services/retrieval.py +302 -0
  72. contextos/services/retrieval_index.py +88 -0
  73. contextos/services/router.py +302 -0
  74. contextos/services/secret_scanner.py +207 -0
  75. contextos/services/telemetry_query.py +102 -0
  76. contextos/services/temporal.py +500 -0
  77. contextos/services/token_counter.py +222 -0
  78. contextos/storage/__init__.py +1 -0
  79. contextos/storage/connector_repo.py +67 -0
  80. contextos/storage/database.py +497 -0
  81. contextos/storage/event_repo.py +137 -0
  82. contextos/storage/graph_repo.py +228 -0
  83. contextos/storage/lexical/__init__.py +1 -0
  84. contextos/storage/lexical/bm25.py +134 -0
  85. contextos/storage/memory_repo.py +589 -0
  86. contextos/storage/relation_repo.py +80 -0
  87. contextos/storage/telemetry_repo.py +481 -0
  88. contextos/storage/vector/__init__.py +1 -0
  89. contextos/storage/vector/in_memory.py +162 -0
  90. contextos_memory_runtime-1.0.0rc2.dist-info/METADATA +143 -0
  91. contextos_memory_runtime-1.0.0rc2.dist-info/RECORD +93 -0
  92. contextos_memory_runtime-1.0.0rc2.dist-info/WHEEL +4 -0
  93. 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."""