devtorch-core 3.0.1__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 (193) hide show
  1. devtorch_core/__init__.py +158 -0
  2. devtorch_core/aggphi_textual.py +275 -0
  3. devtorch_core/alerts/__init__.py +23 -0
  4. devtorch_core/alerts/base.py +46 -0
  5. devtorch_core/alerts/config.py +60 -0
  6. devtorch_core/alerts/dispatcher.py +110 -0
  7. devtorch_core/alerts/jira.py +96 -0
  8. devtorch_core/alerts/linear.py +72 -0
  9. devtorch_core/alerts/pagerduty.py +66 -0
  10. devtorch_core/alerts/slack.py +81 -0
  11. devtorch_core/alerts/teams.py +70 -0
  12. devtorch_core/audit/__init__.py +43 -0
  13. devtorch_core/audit/exporter.py +297 -0
  14. devtorch_core/audit/privacy.py +101 -0
  15. devtorch_core/audit/scrubber.py +149 -0
  16. devtorch_core/audit/service.py +67 -0
  17. devtorch_core/audit/signing.py +127 -0
  18. devtorch_core/broadcast/__init__.py +4 -0
  19. devtorch_core/broadcast/broadcaster.py +100 -0
  20. devtorch_core/broadcast/watcher.py +71 -0
  21. devtorch_core/capability.py +639 -0
  22. devtorch_core/cloud/__init__.py +1 -0
  23. devtorch_core/cloud/client_config.py +472 -0
  24. devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
  25. devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
  26. devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
  27. devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
  28. devtorch_core/cloud/client_configs/.opencode.json +15 -0
  29. devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
  30. devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
  31. devtorch_core/cloud/mcp_client.py +229 -0
  32. devtorch_core/cloud/setup.py +144 -0
  33. devtorch_core/cloud/sync.py +143 -0
  34. devtorch_core/cloud/sync_bundle.py +603 -0
  35. devtorch_core/cloud/sync_conflicts.py +159 -0
  36. devtorch_core/cloud/sync_state.py +159 -0
  37. devtorch_core/cloud/team_sync.py +283 -0
  38. devtorch_core/codex/__init__.py +9 -0
  39. devtorch_core/codex/__main__.py +97 -0
  40. devtorch_core/codex/capture.py +208 -0
  41. devtorch_core/codex/proxy.py +412 -0
  42. devtorch_core/concept_catalog.py +209 -0
  43. devtorch_core/consolidation/__init__.py +3 -0
  44. devtorch_core/consolidation/synthesizer.py +87 -0
  45. devtorch_core/consolidation/workflow.py +175 -0
  46. devtorch_core/daemon/__init__.py +27 -0
  47. devtorch_core/daemon/supervisor.py +293 -0
  48. devtorch_core/daemon/watcher.py +244 -0
  49. devtorch_core/dashboard_api.py +2012 -0
  50. devtorch_core/deltaf.py +97 -0
  51. devtorch_core/disclosure.py +50 -0
  52. devtorch_core/divergence/__init__.py +3 -0
  53. devtorch_core/divergence/detector.py +166 -0
  54. devtorch_core/gateway/__init__.py +32 -0
  55. devtorch_core/gateway/key_manager.py +124 -0
  56. devtorch_core/gateway/metrics_webhook.py +252 -0
  57. devtorch_core/gateway/policy.py +262 -0
  58. devtorch_core/gateway/server.py +727 -0
  59. devtorch_core/gateway/sso.py +233 -0
  60. devtorch_core/gcc.py +1246 -0
  61. devtorch_core/github/__init__.py +35 -0
  62. devtorch_core/github/app.py +240 -0
  63. devtorch_core/github/comment_builder.py +113 -0
  64. devtorch_core/github/pat.py +76 -0
  65. devtorch_core/github/pr_parser.py +82 -0
  66. devtorch_core/github/pr_reporter.py +555 -0
  67. devtorch_core/gitlab/__init__.py +177 -0
  68. devtorch_core/hitl/__init__.py +4 -0
  69. devtorch_core/hitl/channels.py +129 -0
  70. devtorch_core/hitl/orchestrator.py +95 -0
  71. devtorch_core/hooks/__init__.py +17 -0
  72. devtorch_core/hooks/claude_code.py +228 -0
  73. devtorch_core/hooks/git_capture.py +341 -0
  74. devtorch_core/hooks/git_commit.py +182 -0
  75. devtorch_core/hooks/installer.py +733 -0
  76. devtorch_core/hooks/pre_commit.py +157 -0
  77. devtorch_core/hooks/runner.py +344 -0
  78. devtorch_core/identity/__init__.py +4 -0
  79. devtorch_core/identity/agent.py +86 -0
  80. devtorch_core/identity/providers.py +85 -0
  81. devtorch_core/invariants.py +182 -0
  82. devtorch_core/mcp/__init__.py +10 -0
  83. devtorch_core/mcp/auth.py +177 -0
  84. devtorch_core/mcp/server.py +1049 -0
  85. devtorch_core/metrics/__init__.py +35 -0
  86. devtorch_core/metrics/aggregate.py +215 -0
  87. devtorch_core/metrics/calibrate.py +198 -0
  88. devtorch_core/metrics/calibration.py +125 -0
  89. devtorch_core/metrics/credibility.py +288 -0
  90. devtorch_core/metrics/delivery_time.py +70 -0
  91. devtorch_core/metrics/dhs.py +126 -0
  92. devtorch_core/metrics/mcs.py +96 -0
  93. devtorch_core/metrics/roi.py +88 -0
  94. devtorch_core/metrics/session_writer.py +81 -0
  95. devtorch_core/metrics/shadow_ai.py +117 -0
  96. devtorch_core/metrics/sprint_writer.py +243 -0
  97. devtorch_core/observability/__init__.py +78 -0
  98. devtorch_core/observability/datadog.py +157 -0
  99. devtorch_core/observability/formatter.py +119 -0
  100. devtorch_core/observability/report.py +264 -0
  101. devtorch_core/observability/servicenow.py +147 -0
  102. devtorch_core/observability/splunk.py +218 -0
  103. devtorch_core/observability/webhook.py +227 -0
  104. devtorch_core/parser/__init__.py +30 -0
  105. devtorch_core/parser/blocks.py +216 -0
  106. devtorch_core/parser/inference.py +159 -0
  107. devtorch_core/parser/thinking.py +112 -0
  108. devtorch_core/projects.py +169 -0
  109. devtorch_core/prompt_artifact.py +76 -0
  110. devtorch_core/proxy/__init__.py +9 -0
  111. devtorch_core/proxy/routes/__init__.py +1 -0
  112. devtorch_core/proxy/routes/anthropic.py +264 -0
  113. devtorch_core/proxy/routes/azure_openai.py +336 -0
  114. devtorch_core/proxy/routes/gemini.py +331 -0
  115. devtorch_core/proxy/routes/groq.py +284 -0
  116. devtorch_core/proxy/routes/ollama.py +279 -0
  117. devtorch_core/proxy/routes/openai.py +287 -0
  118. devtorch_core/proxy/server.py +356 -0
  119. devtorch_core/query/__init__.py +15 -0
  120. devtorch_core/query/grep.py +181 -0
  121. devtorch_core/query/hybrid.py +86 -0
  122. devtorch_core/query/semantic.py +157 -0
  123. devtorch_core/rdp.py +105 -0
  124. devtorch_core/reasoning/__init__.py +4 -0
  125. devtorch_core/reasoning/entry.py +31 -0
  126. devtorch_core/reasoning/store.py +122 -0
  127. devtorch_core/reasoning_plus/__init__.py +70 -0
  128. devtorch_core/reasoning_plus/augmenter.py +326 -0
  129. devtorch_core/reasoning_plus/capture.py +51 -0
  130. devtorch_core/reasoning_plus/config.py +256 -0
  131. devtorch_core/reasoning_plus/context.py +262 -0
  132. devtorch_core/reasoning_plus/learning/__init__.py +72 -0
  133. devtorch_core/reasoning_plus/learning/analytics.py +141 -0
  134. devtorch_core/reasoning_plus/learning/api.py +313 -0
  135. devtorch_core/reasoning_plus/learning/chain.py +285 -0
  136. devtorch_core/reasoning_plus/learning/composer.py +74 -0
  137. devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
  138. devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
  139. devtorch_core/reasoning_plus/learning/extractor.py +207 -0
  140. devtorch_core/reasoning_plus/learning/models.py +116 -0
  141. devtorch_core/reasoning_plus/learning/provenance.py +126 -0
  142. devtorch_core/reasoning_plus/learning/recorder.py +81 -0
  143. devtorch_core/reasoning_plus/learning/relevance.py +122 -0
  144. devtorch_core/reasoning_plus/learning/state.py +86 -0
  145. devtorch_core/reasoning_plus/learning/store.py +160 -0
  146. devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
  147. devtorch_core/reasoning_plus/prompt.py +90 -0
  148. devtorch_core/rep.py +134 -0
  149. devtorch_core/rep_network/__init__.py +25 -0
  150. devtorch_core/rep_network/merge.py +70 -0
  151. devtorch_core/rep_network/node.py +137 -0
  152. devtorch_core/rep_network/server.py +140 -0
  153. devtorch_core/rep_network/sync.py +207 -0
  154. devtorch_core/sensitivity.py +182 -0
  155. devtorch_core/serve.py +258 -0
  156. devtorch_core/session/__init__.py +39 -0
  157. devtorch_core/session/disagreement.py +188 -0
  158. devtorch_core/session/models.py +114 -0
  159. devtorch_core/session/orchestrator.py +182 -0
  160. devtorch_core/session/planner.py +169 -0
  161. devtorch_core/session/simulator.py +132 -0
  162. devtorch_core/signing.py +290 -0
  163. devtorch_core/sis.py +197 -0
  164. devtorch_core/storage.py +308 -0
  165. devtorch_core/templates/__init__.py +6 -0
  166. devtorch_core/templates/engine.py +122 -0
  167. devtorch_core/templates/go.py +18 -0
  168. devtorch_core/templates/infra.py +19 -0
  169. devtorch_core/templates/library/__init__.py +18 -0
  170. devtorch_core/templates/library/api_design.md +27 -0
  171. devtorch_core/templates/library/bug_fix.md +27 -0
  172. devtorch_core/templates/library/decision_record.md +27 -0
  173. devtorch_core/templates/library/engine.py +228 -0
  174. devtorch_core/templates/library/security_review.md +30 -0
  175. devtorch_core/templates/python.py +19 -0
  176. devtorch_core/templates/react.py +18 -0
  177. devtorch_core/templates/typescript.py +18 -0
  178. devtorch_core/theta.py +221 -0
  179. devtorch_core/theta_synthesis.py +268 -0
  180. devtorch_core/topics.py +320 -0
  181. devtorch_core/variance.py +219 -0
  182. devtorch_core/wrapper/__init__.py +52 -0
  183. devtorch_core/wrapper/anthropic.py +487 -0
  184. devtorch_core/wrapper/base.py +562 -0
  185. devtorch_core/wrapper/bedrock.py +342 -0
  186. devtorch_core/wrapper/gemini.py +422 -0
  187. devtorch_core/wrapper/ollama.py +527 -0
  188. devtorch_core/wrapper/openai.py +461 -0
  189. devtorch_core-3.0.1.dist-info/METADATA +867 -0
  190. devtorch_core-3.0.1.dist-info/RECORD +193 -0
  191. devtorch_core-3.0.1.dist-info/WHEEL +5 -0
  192. devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
  193. devtorch_core-3.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,527 @@
1
+ """
2
+ devtorch_core.wrapper.ollama
3
+ =============================
4
+ Drop-in Ollama client wrapper that:
5
+
6
+ 1. Injects the RACP system-prompt prefix as the first system message in every
7
+ ``chat()`` call.
8
+ 2. Captures RACP blocks / inferred sensitivity from the response to the .GCC/
9
+ store.
10
+ 3. Degrades gracefully — if Ollama is not running, returns an error dict
11
+ without raising.
12
+ 4. Uses the OpenAI-compatible endpoint (``/v1/chat/completions``) so the same
13
+ parsing pipeline works as with DevTorchOpenAI.
14
+
15
+ Usage
16
+ -----
17
+ ::
18
+
19
+ from devtorch_core.wrapper.ollama import DevTorchOllama
20
+
21
+ client = DevTorchOllama(model="llama3.2")
22
+ response = client.chat([{"role": "user", "content": "Hello"}])
23
+ # response = {"choices": [{"message": {"content": "Hi there!"}}], ...}
24
+
25
+ # Streaming
26
+ for chunk in client.chat([{"role": "user", "content": "Hello"}], stream=True):
27
+ print(chunk["choices"][0]["delta"]["content"], end="", flush=True)
28
+
29
+ Environment variables
30
+ ---------------------
31
+ DEVTORCH_DISABLE=1 Disable all injection and capture (pass-through mode).
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import logging
37
+ import uuid
38
+ from typing import Any, Iterator
39
+
40
+ from .base import CaptureOrchestrator, RACPInjector, _reasoning_text, find_gcc_repo, is_disabled
41
+
42
+ logger = logging.getLogger("devtorch.wrapper.ollama")
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Optional httpx — fall back to urllib.request if not available
46
+ # ---------------------------------------------------------------------------
47
+
48
+ try:
49
+ import httpx as _httpx
50
+
51
+ HAS_HTTPX = True
52
+ except ImportError:
53
+ HAS_HTTPX = False
54
+ _httpx = None # type: ignore[assignment]
55
+
56
+ import urllib.request
57
+ import urllib.error
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Wrapper
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ class DevTorchOllama:
66
+ """
67
+ Ollama LLM wrapper with RACP injection and .GCC/ capture.
68
+
69
+ Uses the OpenAI-compatible endpoint at ``{base_url}/v1/chat/completions``
70
+ so the same response parsing pipeline as DevTorchOpenAI applies.
71
+
72
+ All errors are caught internally — never raised to the caller.
73
+ Ollama models are treated as inference-fallback (confidence tier ≤ 0.5)
74
+ because they do not emit thinking tokens or RACP blocks in practice.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ model: str = "llama3.2",
80
+ base_url: str = "http://localhost:11434",
81
+ gcc_repo_path: str | None = None,
82
+ racp_prompt: str | None = None,
83
+ ) -> None:
84
+ self.model = model
85
+ self.base_url = base_url.rstrip("/")
86
+ self._gcc_repo_path = gcc_repo_path
87
+ self._racp_prompt = racp_prompt
88
+
89
+ # Resolve GCC repo
90
+ self._gcc = find_gcc_repo()
91
+ self._injector = RACPInjector(self._gcc) if self._gcc else None
92
+ self._capture = CaptureOrchestrator(self._gcc) if self._gcc else None
93
+
94
+ # ------------------------------------------------------------------
95
+ # Public API
96
+ # ------------------------------------------------------------------
97
+
98
+ def chat(
99
+ self,
100
+ messages: list[dict],
101
+ stream: bool = False,
102
+ **kwargs: Any,
103
+ ) -> dict | Iterator[dict]:
104
+ """
105
+ Send a chat request to Ollama.
106
+
107
+ Parameters
108
+ ----------
109
+ messages:
110
+ List of message dicts in OpenAI format
111
+ (e.g. ``[{"role": "user", "content": "Hello"}]``).
112
+ stream:
113
+ If True, yields response chunks as dicts; otherwise returns a
114
+ single response dict.
115
+ **kwargs:
116
+ Extra fields forwarded to the request body (e.g. ``temperature``).
117
+
118
+ Returns
119
+ -------
120
+ dict
121
+ OpenAI-style response dict on success. On error, returns
122
+ ``{"error": "<message>", "choices": []}``.
123
+ Iterator[dict]
124
+ When ``stream=True``, yields dicts with
125
+ ``choices[0]["delta"]["content"]`` populated.
126
+ """
127
+ session_id = str(uuid.uuid4())[:8]
128
+ call_id = str(uuid.uuid4())
129
+ prompt_text = _extract_prompt_text(messages)
130
+ injected_messages = self._inject(list(messages), prompt_text, session_id, call_id, self.model)
131
+
132
+ if stream:
133
+ return self._stream_chat(injected_messages, session_id, call_id, **kwargs)
134
+
135
+ return self._non_stream_chat(injected_messages, session_id, call_id, **kwargs)
136
+
137
+ def list_models(self) -> list[str]:
138
+ """
139
+ Return a list of model names available in the local Ollama instance.
140
+
141
+ Returns an empty list on any error (e.g. Ollama not running).
142
+ """
143
+ url = f"{self.base_url}/api/tags"
144
+ try:
145
+ data = self._get(url)
146
+ models = data.get("models", [])
147
+ return [m.get("name", "") for m in models if m.get("name")]
148
+ except Exception as exc:
149
+ logger.warning("devtorch.ollama: list_models failed — %s", exc)
150
+ return []
151
+
152
+ # ------------------------------------------------------------------
153
+ # Internal: non-streaming
154
+ # ------------------------------------------------------------------
155
+
156
+ def _non_stream_chat(
157
+ self,
158
+ messages: list[dict],
159
+ session_id: str,
160
+ call_id: str = "",
161
+ **kwargs: Any,
162
+ ) -> dict:
163
+ url = f"{self.base_url}/v1/chat/completions"
164
+ payload = {
165
+ "model": self.model,
166
+ "messages": messages,
167
+ "stream": False,
168
+ **kwargs,
169
+ }
170
+ prompt_text = _extract_prompt_text(messages)
171
+ try:
172
+ response = self._post(url, payload)
173
+ except Exception as exc:
174
+ logger.warning("devtorch.ollama: chat failed — %s", exc)
175
+ self._record_llm_outcome(
176
+ prompt_text=prompt_text,
177
+ response_text="",
178
+ thinking_blocks=[],
179
+ session_id=session_id,
180
+ call_id=call_id,
181
+ model_name=self.model,
182
+ outcome="failure",
183
+ )
184
+ return {"error": str(exc), "choices": []}
185
+
186
+ # Capture after successful response
187
+ text, thinking_blocks = self._capture_response(response, session_id)
188
+ self._record_llm_outcome(
189
+ prompt_text=prompt_text,
190
+ response_text=text,
191
+ thinking_blocks=thinking_blocks,
192
+ session_id=session_id,
193
+ call_id=call_id,
194
+ model_name=self.model,
195
+ outcome="success",
196
+ )
197
+ return response
198
+
199
+ # ------------------------------------------------------------------
200
+ # Internal: streaming
201
+ # ------------------------------------------------------------------
202
+
203
+ def _stream_chat(
204
+ self,
205
+ messages: list[dict],
206
+ session_id: str,
207
+ call_id: str = "",
208
+ **kwargs: Any,
209
+ ) -> Iterator[dict]:
210
+ """
211
+ Yield response chunks from Ollama's streaming endpoint.
212
+
213
+ Each yielded dict has the OpenAI-chunk shape:
214
+ ``{"choices": [{"delta": {"content": "..."}}]}``.
215
+
216
+ After the stream ends, fires capture and records the outcome.
217
+ """
218
+ url = f"{self.base_url}/v1/chat/completions"
219
+ payload = {
220
+ "model": self.model,
221
+ "messages": messages,
222
+ "stream": True,
223
+ **kwargs,
224
+ }
225
+ prompt_text = _extract_prompt_text(messages)
226
+ accumulated: list[str] = []
227
+ try:
228
+ yield from self._stream_post(url, payload, accumulated)
229
+ except Exception as exc:
230
+ logger.warning("devtorch.ollama: stream_chat failed — %s", exc)
231
+ self._record_llm_outcome(
232
+ prompt_text=prompt_text,
233
+ response_text="",
234
+ thinking_blocks=[],
235
+ session_id=session_id,
236
+ call_id=call_id,
237
+ model_name=self.model,
238
+ outcome="failure",
239
+ )
240
+ yield {"error": str(exc), "choices": []}
241
+ return
242
+
243
+ # Fire capture with whatever we accumulated
244
+ full_text = "".join(accumulated)
245
+ self._capture_text(full_text, session_id)
246
+ self._record_llm_outcome(
247
+ prompt_text=prompt_text,
248
+ response_text=full_text,
249
+ thinking_blocks=[],
250
+ session_id=session_id,
251
+ call_id=call_id,
252
+ model_name=self.model,
253
+ outcome="success",
254
+ )
255
+
256
+ # ------------------------------------------------------------------
257
+ # Injection
258
+ # ------------------------------------------------------------------
259
+
260
+ def _inject(
261
+ self,
262
+ messages: list[dict],
263
+ prompt_text: str = "",
264
+ session_id: str = "",
265
+ call_id: str = "",
266
+ model_name: str = "",
267
+ ) -> list[dict]:
268
+ """Prepend the RACP system prefix (with Reasoning Plus) as a system message."""
269
+ if is_disabled() or not self._injector:
270
+ return messages
271
+ try:
272
+ prefix, _ = self._injector.build_system_prefix_with_provenance(
273
+ prompt_text=prompt_text or None,
274
+ session_id=session_id,
275
+ call_id=call_id,
276
+ model_name=model_name,
277
+ )
278
+ if prefix:
279
+ system_msg = {"role": "system", "content": prefix}
280
+ return [system_msg] + messages
281
+ except Exception as exc:
282
+ logger.warning("devtorch.ollama: RACP injection failed — %s", exc)
283
+ return messages
284
+
285
+ # ------------------------------------------------------------------
286
+ # Capture
287
+ # ------------------------------------------------------------------
288
+
289
+ def _capture_response(self, response: dict, session_id: str) -> tuple[str, list]:
290
+ """Extract text from response dict, fire CaptureOrchestrator, then strip thinking."""
291
+ text = ""
292
+ thinking: list = []
293
+ if not self._capture or is_disabled():
294
+ return text, thinking
295
+ try:
296
+ text = _extract_text(response)
297
+ # Capture first so inline <thinking> blocks are preserved in the
298
+ # audit trail; the user-facing response is stripped afterwards.
299
+ self._capture.capture(text, thinking, session_id)
300
+ from devtorch_core.reasoning_plus.capture import strip_inline_thinking
301
+ text = strip_inline_thinking(text)
302
+ except Exception as exc:
303
+ logger.warning("devtorch.ollama: _capture_response failed — %s", exc)
304
+ return text, thinking
305
+
306
+ def _capture_text(self, text: str, session_id: str) -> None:
307
+ """Fire CaptureOrchestrator with pre-accumulated text, then strip thinking."""
308
+ if not self._capture or is_disabled():
309
+ return
310
+ try:
311
+ self._capture.capture(text, [], session_id)
312
+ from devtorch_core.reasoning_plus.capture import strip_inline_thinking
313
+ text = strip_inline_thinking(text)
314
+ except Exception as exc:
315
+ logger.warning("devtorch.ollama: _capture_text failed — %s", exc)
316
+
317
+ def _record_llm_outcome(
318
+ self,
319
+ *,
320
+ prompt_text: str,
321
+ response_text: str,
322
+ thinking_blocks: list,
323
+ session_id: str,
324
+ call_id: str,
325
+ model_name: str,
326
+ outcome: str,
327
+ ) -> None:
328
+ if not self._capture or is_disabled():
329
+ return
330
+ try:
331
+ reasoning = _reasoning_text(thinking_blocks)
332
+ self._capture.record_learning_call(
333
+ call_type="llm",
334
+ reasoning=reasoning,
335
+ session_id=session_id,
336
+ call_id=call_id,
337
+ name=model_name,
338
+ input=prompt_text,
339
+ output=response_text,
340
+ outcome=outcome,
341
+ )
342
+ except Exception as exc:
343
+ logger.warning("devtorch.ollama: _record_llm_outcome failed — %s", exc)
344
+
345
+ # ------------------------------------------------------------------
346
+ # HTTP helpers — prefer httpx, fall back to urllib.request
347
+ # ------------------------------------------------------------------
348
+
349
+ def _post(self, url: str, payload: dict) -> dict:
350
+ """POST *payload* as JSON to *url*, return parsed response dict."""
351
+ body = json.dumps(payload).encode("utf-8")
352
+
353
+ if HAS_HTTPX:
354
+ try:
355
+ with _httpx.Client(timeout=120.0) as client: # type: ignore[union-attr]
356
+ resp = client.post(
357
+ url,
358
+ content=body,
359
+ headers={"Content-Type": "application/json"},
360
+ )
361
+ resp.raise_for_status()
362
+ return resp.json()
363
+ except _httpx.ConnectError as exc: # type: ignore[union-attr]
364
+ raise ConnectionError(f"Ollama not reachable at {self.base_url}: {exc}") from exc
365
+
366
+ # --- urllib.request fallback ---
367
+ req = urllib.request.Request(
368
+ url,
369
+ data=body,
370
+ headers={"Content-Type": "application/json"},
371
+ method="POST",
372
+ )
373
+ try:
374
+ with urllib.request.urlopen(req, timeout=120) as resp:
375
+ return json.loads(resp.read().decode("utf-8"))
376
+ except urllib.error.URLError as exc:
377
+ raise ConnectionError(f"Ollama not reachable at {self.base_url}: {exc}") from exc
378
+
379
+ def _get(self, url: str) -> dict:
380
+ """GET *url*, return parsed JSON dict."""
381
+ if HAS_HTTPX:
382
+ try:
383
+ with _httpx.Client(timeout=30.0) as client: # type: ignore[union-attr]
384
+ resp = client.get(url)
385
+ resp.raise_for_status()
386
+ return resp.json()
387
+ except _httpx.ConnectError as exc: # type: ignore[union-attr]
388
+ raise ConnectionError(f"Ollama not reachable at {self.base_url}: {exc}") from exc
389
+
390
+ req = urllib.request.Request(url, method="GET")
391
+ try:
392
+ with urllib.request.urlopen(req, timeout=30) as resp:
393
+ return json.loads(resp.read().decode("utf-8"))
394
+ except urllib.error.URLError as exc:
395
+ raise ConnectionError(f"Ollama not reachable at {self.base_url}: {exc}") from exc
396
+
397
+ def _stream_post(
398
+ self,
399
+ url: str,
400
+ payload: dict,
401
+ accumulated: list[str],
402
+ ) -> Iterator[dict]:
403
+ """
404
+ POST *payload* with streaming enabled; yield parsed chunk dicts.
405
+ Appends content strings to *accumulated* for post-stream capture.
406
+ """
407
+ body = json.dumps(payload).encode("utf-8")
408
+
409
+ if HAS_HTTPX:
410
+ yield from self._stream_post_httpx(url, body, accumulated)
411
+ else:
412
+ yield from self._stream_post_urllib(url, body, accumulated)
413
+
414
+ def _stream_post_httpx(
415
+ self,
416
+ url: str,
417
+ body: bytes,
418
+ accumulated: list[str],
419
+ ) -> Iterator[dict]:
420
+ try:
421
+ with _httpx.Client(timeout=120.0) as client: # type: ignore[union-attr]
422
+ with client.stream(
423
+ "POST",
424
+ url,
425
+ content=body,
426
+ headers={"Content-Type": "application/json"},
427
+ ) as resp:
428
+ resp.raise_for_status()
429
+ for line in resp.iter_lines():
430
+ chunk = _parse_sse_line(line, accumulated)
431
+ if chunk is not None:
432
+ yield chunk
433
+ except _httpx.ConnectError as exc: # type: ignore[union-attr]
434
+ raise ConnectionError(
435
+ f"Ollama not reachable at {self.base_url}: {exc}"
436
+ ) from exc
437
+
438
+ def _stream_post_urllib(
439
+ self,
440
+ url: str,
441
+ body: bytes,
442
+ accumulated: list[str],
443
+ ) -> Iterator[dict]:
444
+ req = urllib.request.Request(
445
+ url,
446
+ data=body,
447
+ headers={"Content-Type": "application/json"},
448
+ method="POST",
449
+ )
450
+ try:
451
+ with urllib.request.urlopen(req, timeout=120) as resp:
452
+ for raw_line in resp:
453
+ line = raw_line.decode("utf-8").rstrip("\n\r")
454
+ chunk = _parse_sse_line(line, accumulated)
455
+ if chunk is not None:
456
+ yield chunk
457
+ except urllib.error.URLError as exc:
458
+ raise ConnectionError(
459
+ f"Ollama not reachable at {self.base_url}: {exc}"
460
+ ) from exc
461
+
462
+
463
+ # ---------------------------------------------------------------------------
464
+ # Module-level helpers
465
+ # ---------------------------------------------------------------------------
466
+
467
+
468
+ def _extract_text(response: dict) -> str:
469
+ """Extract plain text content from an OpenAI-style response dict."""
470
+ try:
471
+ choices = response.get("choices", [])
472
+ if choices:
473
+ msg = choices[0].get("message", {})
474
+ return msg.get("content", "") or ""
475
+ except Exception:
476
+ pass
477
+ return ""
478
+
479
+
480
+ def _extract_prompt_text(messages: list[dict]) -> str:
481
+ """Best-effort concatenation of user-visible message text for smart context."""
482
+ parts: list[str] = []
483
+ for m in messages or []:
484
+ if isinstance(m, dict):
485
+ role = m.get("role", "")
486
+ content = m.get("content", "")
487
+ if role in ("user", "system") and content:
488
+ parts.append(str(content))
489
+ return "\n".join(parts)[:4000]
490
+
491
+
492
+ def _parse_sse_line(line: str, accumulated: list[str]) -> dict | None:
493
+ """
494
+ Parse a single Server-Sent Events line from the streaming response.
495
+
496
+ OpenAI-compatible stream format: ``data: <json>`` or ``data: [DONE]``.
497
+ Returns a chunk dict or None if the line should be skipped.
498
+ Appends delta content to *accumulated*.
499
+ """
500
+ line = line.strip()
501
+ if not line:
502
+ return None
503
+
504
+ # Strip SSE "data: " prefix if present
505
+ if line.startswith("data:"):
506
+ line = line[5:].strip()
507
+
508
+ if line == "[DONE]":
509
+ return None
510
+
511
+ try:
512
+ chunk = json.loads(line)
513
+ except json.JSONDecodeError:
514
+ return None
515
+
516
+ # Accumulate content from delta
517
+ try:
518
+ choices = chunk.get("choices", [])
519
+ if choices:
520
+ delta = choices[0].get("delta", {})
521
+ content = delta.get("content")
522
+ if content:
523
+ accumulated.append(content)
524
+ except Exception:
525
+ pass
526
+
527
+ return chunk