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.
- devtorch_core/__init__.py +158 -0
- devtorch_core/aggphi_textual.py +275 -0
- devtorch_core/alerts/__init__.py +23 -0
- devtorch_core/alerts/base.py +46 -0
- devtorch_core/alerts/config.py +60 -0
- devtorch_core/alerts/dispatcher.py +110 -0
- devtorch_core/alerts/jira.py +96 -0
- devtorch_core/alerts/linear.py +72 -0
- devtorch_core/alerts/pagerduty.py +66 -0
- devtorch_core/alerts/slack.py +81 -0
- devtorch_core/alerts/teams.py +70 -0
- devtorch_core/audit/__init__.py +43 -0
- devtorch_core/audit/exporter.py +297 -0
- devtorch_core/audit/privacy.py +101 -0
- devtorch_core/audit/scrubber.py +149 -0
- devtorch_core/audit/service.py +67 -0
- devtorch_core/audit/signing.py +127 -0
- devtorch_core/broadcast/__init__.py +4 -0
- devtorch_core/broadcast/broadcaster.py +100 -0
- devtorch_core/broadcast/watcher.py +71 -0
- devtorch_core/capability.py +639 -0
- devtorch_core/cloud/__init__.py +1 -0
- devtorch_core/cloud/client_config.py +472 -0
- devtorch_core/cloud/client_configs/.claude-opencode-fallback.json +8 -0
- devtorch_core/cloud/client_configs/.claude-stdio.json +13 -0
- devtorch_core/cloud/client_configs/.cursor-mcp.json +13 -0
- devtorch_core/cloud/client_configs/.opencode-bridge.json +13 -0
- devtorch_core/cloud/client_configs/.opencode.json +15 -0
- devtorch_core/cloud/client_configs/.vscode-mcp.json +13 -0
- devtorch_core/cloud/devtorch-mcp-bridge.js +357 -0
- devtorch_core/cloud/mcp_client.py +229 -0
- devtorch_core/cloud/setup.py +144 -0
- devtorch_core/cloud/sync.py +143 -0
- devtorch_core/cloud/sync_bundle.py +603 -0
- devtorch_core/cloud/sync_conflicts.py +159 -0
- devtorch_core/cloud/sync_state.py +159 -0
- devtorch_core/cloud/team_sync.py +283 -0
- devtorch_core/codex/__init__.py +9 -0
- devtorch_core/codex/__main__.py +97 -0
- devtorch_core/codex/capture.py +208 -0
- devtorch_core/codex/proxy.py +412 -0
- devtorch_core/concept_catalog.py +209 -0
- devtorch_core/consolidation/__init__.py +3 -0
- devtorch_core/consolidation/synthesizer.py +87 -0
- devtorch_core/consolidation/workflow.py +175 -0
- devtorch_core/daemon/__init__.py +27 -0
- devtorch_core/daemon/supervisor.py +293 -0
- devtorch_core/daemon/watcher.py +244 -0
- devtorch_core/dashboard_api.py +2012 -0
- devtorch_core/deltaf.py +97 -0
- devtorch_core/disclosure.py +50 -0
- devtorch_core/divergence/__init__.py +3 -0
- devtorch_core/divergence/detector.py +166 -0
- devtorch_core/gateway/__init__.py +32 -0
- devtorch_core/gateway/key_manager.py +124 -0
- devtorch_core/gateway/metrics_webhook.py +252 -0
- devtorch_core/gateway/policy.py +262 -0
- devtorch_core/gateway/server.py +727 -0
- devtorch_core/gateway/sso.py +233 -0
- devtorch_core/gcc.py +1246 -0
- devtorch_core/github/__init__.py +35 -0
- devtorch_core/github/app.py +240 -0
- devtorch_core/github/comment_builder.py +113 -0
- devtorch_core/github/pat.py +76 -0
- devtorch_core/github/pr_parser.py +82 -0
- devtorch_core/github/pr_reporter.py +555 -0
- devtorch_core/gitlab/__init__.py +177 -0
- devtorch_core/hitl/__init__.py +4 -0
- devtorch_core/hitl/channels.py +129 -0
- devtorch_core/hitl/orchestrator.py +95 -0
- devtorch_core/hooks/__init__.py +17 -0
- devtorch_core/hooks/claude_code.py +228 -0
- devtorch_core/hooks/git_capture.py +341 -0
- devtorch_core/hooks/git_commit.py +182 -0
- devtorch_core/hooks/installer.py +733 -0
- devtorch_core/hooks/pre_commit.py +157 -0
- devtorch_core/hooks/runner.py +344 -0
- devtorch_core/identity/__init__.py +4 -0
- devtorch_core/identity/agent.py +86 -0
- devtorch_core/identity/providers.py +85 -0
- devtorch_core/invariants.py +182 -0
- devtorch_core/mcp/__init__.py +10 -0
- devtorch_core/mcp/auth.py +177 -0
- devtorch_core/mcp/server.py +1049 -0
- devtorch_core/metrics/__init__.py +35 -0
- devtorch_core/metrics/aggregate.py +215 -0
- devtorch_core/metrics/calibrate.py +198 -0
- devtorch_core/metrics/calibration.py +125 -0
- devtorch_core/metrics/credibility.py +288 -0
- devtorch_core/metrics/delivery_time.py +70 -0
- devtorch_core/metrics/dhs.py +126 -0
- devtorch_core/metrics/mcs.py +96 -0
- devtorch_core/metrics/roi.py +88 -0
- devtorch_core/metrics/session_writer.py +81 -0
- devtorch_core/metrics/shadow_ai.py +117 -0
- devtorch_core/metrics/sprint_writer.py +243 -0
- devtorch_core/observability/__init__.py +78 -0
- devtorch_core/observability/datadog.py +157 -0
- devtorch_core/observability/formatter.py +119 -0
- devtorch_core/observability/report.py +264 -0
- devtorch_core/observability/servicenow.py +147 -0
- devtorch_core/observability/splunk.py +218 -0
- devtorch_core/observability/webhook.py +227 -0
- devtorch_core/parser/__init__.py +30 -0
- devtorch_core/parser/blocks.py +216 -0
- devtorch_core/parser/inference.py +159 -0
- devtorch_core/parser/thinking.py +112 -0
- devtorch_core/projects.py +169 -0
- devtorch_core/prompt_artifact.py +76 -0
- devtorch_core/proxy/__init__.py +9 -0
- devtorch_core/proxy/routes/__init__.py +1 -0
- devtorch_core/proxy/routes/anthropic.py +264 -0
- devtorch_core/proxy/routes/azure_openai.py +336 -0
- devtorch_core/proxy/routes/gemini.py +331 -0
- devtorch_core/proxy/routes/groq.py +284 -0
- devtorch_core/proxy/routes/ollama.py +279 -0
- devtorch_core/proxy/routes/openai.py +287 -0
- devtorch_core/proxy/server.py +356 -0
- devtorch_core/query/__init__.py +15 -0
- devtorch_core/query/grep.py +181 -0
- devtorch_core/query/hybrid.py +86 -0
- devtorch_core/query/semantic.py +157 -0
- devtorch_core/rdp.py +105 -0
- devtorch_core/reasoning/__init__.py +4 -0
- devtorch_core/reasoning/entry.py +31 -0
- devtorch_core/reasoning/store.py +122 -0
- devtorch_core/reasoning_plus/__init__.py +70 -0
- devtorch_core/reasoning_plus/augmenter.py +326 -0
- devtorch_core/reasoning_plus/capture.py +51 -0
- devtorch_core/reasoning_plus/config.py +256 -0
- devtorch_core/reasoning_plus/context.py +262 -0
- devtorch_core/reasoning_plus/learning/__init__.py +72 -0
- devtorch_core/reasoning_plus/learning/analytics.py +141 -0
- devtorch_core/reasoning_plus/learning/api.py +313 -0
- devtorch_core/reasoning_plus/learning/chain.py +285 -0
- devtorch_core/reasoning_plus/learning/composer.py +74 -0
- devtorch_core/reasoning_plus/learning/cross_project.py +234 -0
- devtorch_core/reasoning_plus/learning/embeddings.py +209 -0
- devtorch_core/reasoning_plus/learning/extractor.py +207 -0
- devtorch_core/reasoning_plus/learning/models.py +116 -0
- devtorch_core/reasoning_plus/learning/provenance.py +126 -0
- devtorch_core/reasoning_plus/learning/recorder.py +81 -0
- devtorch_core/reasoning_plus/learning/relevance.py +122 -0
- devtorch_core/reasoning_plus/learning/state.py +86 -0
- devtorch_core/reasoning_plus/learning/store.py +160 -0
- devtorch_core/reasoning_plus/learning/theta_learning_bridge.py +94 -0
- devtorch_core/reasoning_plus/prompt.py +90 -0
- devtorch_core/rep.py +134 -0
- devtorch_core/rep_network/__init__.py +25 -0
- devtorch_core/rep_network/merge.py +70 -0
- devtorch_core/rep_network/node.py +137 -0
- devtorch_core/rep_network/server.py +140 -0
- devtorch_core/rep_network/sync.py +207 -0
- devtorch_core/sensitivity.py +182 -0
- devtorch_core/serve.py +258 -0
- devtorch_core/session/__init__.py +39 -0
- devtorch_core/session/disagreement.py +188 -0
- devtorch_core/session/models.py +114 -0
- devtorch_core/session/orchestrator.py +182 -0
- devtorch_core/session/planner.py +169 -0
- devtorch_core/session/simulator.py +132 -0
- devtorch_core/signing.py +290 -0
- devtorch_core/sis.py +197 -0
- devtorch_core/storage.py +308 -0
- devtorch_core/templates/__init__.py +6 -0
- devtorch_core/templates/engine.py +122 -0
- devtorch_core/templates/go.py +18 -0
- devtorch_core/templates/infra.py +19 -0
- devtorch_core/templates/library/__init__.py +18 -0
- devtorch_core/templates/library/api_design.md +27 -0
- devtorch_core/templates/library/bug_fix.md +27 -0
- devtorch_core/templates/library/decision_record.md +27 -0
- devtorch_core/templates/library/engine.py +228 -0
- devtorch_core/templates/library/security_review.md +30 -0
- devtorch_core/templates/python.py +19 -0
- devtorch_core/templates/react.py +18 -0
- devtorch_core/templates/typescript.py +18 -0
- devtorch_core/theta.py +221 -0
- devtorch_core/theta_synthesis.py +268 -0
- devtorch_core/topics.py +320 -0
- devtorch_core/variance.py +219 -0
- devtorch_core/wrapper/__init__.py +52 -0
- devtorch_core/wrapper/anthropic.py +487 -0
- devtorch_core/wrapper/base.py +562 -0
- devtorch_core/wrapper/bedrock.py +342 -0
- devtorch_core/wrapper/gemini.py +422 -0
- devtorch_core/wrapper/ollama.py +527 -0
- devtorch_core/wrapper/openai.py +461 -0
- devtorch_core-3.0.1.dist-info/METADATA +867 -0
- devtorch_core-3.0.1.dist-info/RECORD +193 -0
- devtorch_core-3.0.1.dist-info/WHEEL +5 -0
- devtorch_core-3.0.1.dist-info/entry_points.txt +2 -0
- 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
|