deepparallel 0.4.3__tar.gz → 0.5.0__tar.gz
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.
- {deepparallel-0.4.3 → deepparallel-0.5.0}/PKG-INFO +1 -1
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/__init__.py +1 -1
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/backend.py +232 -5
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/branding.py +107 -20
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/config.py +35 -1
- deepparallel-0.5.0/deepparallel/crowe_id.py +75 -0
- deepparallel-0.5.0/deepparallel/dsml.py +129 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/renderer.py +4 -10
- deepparallel-0.5.0/deepparallel/research/provider.py +125 -0
- deepparallel-0.5.0/deepparallel/routing.example.json +32 -0
- deepparallel-0.5.0/deepparallel/routing.py +135 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/serve.py +105 -12
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/__init__.py +1 -0
- deepparallel-0.5.0/deepparallel/tools/mcp.py +280 -0
- deepparallel-0.5.0/deepparallel/userinput.py +135 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel.egg-info/PKG-INFO +1 -1
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel.egg-info/SOURCES.txt +17 -1
- {deepparallel-0.4.3 → deepparallel-0.5.0}/pyproject.toml +2 -2
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_backend.py +60 -0
- deepparallel-0.5.0/tests/test_crowe_backend.py +79 -0
- deepparallel-0.5.0/tests/test_crowe_gateway_backend.py +99 -0
- deepparallel-0.5.0/tests/test_crowe_id_auth.py +112 -0
- deepparallel-0.5.0/tests/test_crowe_payment_required.py +70 -0
- deepparallel-0.5.0/tests/test_dsml.py +104 -0
- deepparallel-0.5.0/tests/test_research_provider.py +96 -0
- deepparallel-0.5.0/tests/test_routing.py +169 -0
- deepparallel-0.5.0/tests/test_serve.py +259 -0
- deepparallel-0.5.0/tests/test_tools_mcp.py +47 -0
- deepparallel-0.5.0/tests/test_userinput_paste.py +71 -0
- deepparallel-0.4.3/deepparallel/userinput.py +0 -61
- {deepparallel-0.4.3 → deepparallel-0.5.0}/README.md +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/agent.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/cli.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/fusion.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/licensing.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/registry.json +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/research/__init__.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/research/conduit.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/supply_chain.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/system_prompt.txt +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/codeast.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/edit.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/files.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/registry.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/sandbox.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/search.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/shell.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/vision.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/web.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel.egg-info/dependency_links.txt +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel.egg-info/entry_points.txt +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel.egg-info/requires.txt +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel.egg-info/top_level.txt +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/setup.cfg +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_agent.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_backend_chat.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_backend_stream.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_branding.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_cli.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_config.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_fusion.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_issuer_signer.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_licensing.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_renderer.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_research.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_spinner_color.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_supply_chain.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tool_registry.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tools_codeast.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tools_edit.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tools_files.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tools_sandbox.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tools_search.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tools_shell.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tools_vision.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tools_web.py +0 -0
- {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_userinput.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepparallel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: DeepParallel - a multi-model agentic coding CLI with cross-model Guardian review, served via Crowe Logic.
|
|
5
5
|
Author-email: Michael Crowe <michael@crowelogic.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -16,9 +16,14 @@ from urllib.parse import urlparse
|
|
|
16
16
|
|
|
17
17
|
import httpx
|
|
18
18
|
|
|
19
|
+
from . import crowe_id
|
|
20
|
+
|
|
19
21
|
Chunk = tuple[str, str] # (channel, text)
|
|
20
22
|
|
|
21
23
|
_STREAM_TIMEOUT = httpx.Timeout(120.0, connect=10.0)
|
|
24
|
+
# Modal scale-to-zero cold start can take 2-3 min before the first byte, so the
|
|
25
|
+
# read timeout must be generous; connect stays short.
|
|
26
|
+
_MODAL_TIMEOUT = httpx.Timeout(600.0, connect=15.0)
|
|
22
27
|
_CHECK_TIMEOUT = 4.0
|
|
23
28
|
|
|
24
29
|
|
|
@@ -210,17 +215,24 @@ class AzureBackend:
|
|
|
210
215
|
class FoundryBackend:
|
|
211
216
|
label = "Foundry control plane"
|
|
212
217
|
|
|
213
|
-
def __init__(self, base_url: str, api_key: str, model: str):
|
|
218
|
+
def __init__(self, base_url: str, api_key: str, model: str, token_provider=None):
|
|
214
219
|
self._base_url = (base_url or "").rstrip("/")
|
|
215
220
|
self._api_key = api_key or ""
|
|
216
221
|
self._model = model
|
|
222
|
+
# Optional callable returning a fresh bearer (e.g. a Crowe ID
|
|
223
|
+
# client_credentials token). When set it takes precedence over the static
|
|
224
|
+
# api_key, so the gateway sees a sovereign agent identity per request.
|
|
225
|
+
self._token_provider = token_provider
|
|
217
226
|
|
|
218
227
|
@property
|
|
219
228
|
def _url(self) -> str:
|
|
220
229
|
return f"{self._base_url}/v1/chat/completions"
|
|
221
230
|
|
|
231
|
+
def _bearer(self) -> str:
|
|
232
|
+
return self._token_provider() if self._token_provider else self._api_key
|
|
233
|
+
|
|
222
234
|
def check(self) -> tuple[bool, str]:
|
|
223
|
-
if not self._base_url or not self._api_key:
|
|
235
|
+
if not self._base_url or not (self._api_key or self._token_provider):
|
|
224
236
|
return False, "Foundry base URL or API key not configured."
|
|
225
237
|
try:
|
|
226
238
|
httpx.get(_host(self._base_url), timeout=_CHECK_TIMEOUT)
|
|
@@ -237,7 +249,7 @@ class FoundryBackend:
|
|
|
237
249
|
"max_tokens": max_tokens,
|
|
238
250
|
}
|
|
239
251
|
headers = {
|
|
240
|
-
"authorization": f"Bearer {self.
|
|
252
|
+
"authorization": f"Bearer {self._bearer()}",
|
|
241
253
|
"content-type": "application/json",
|
|
242
254
|
}
|
|
243
255
|
with httpx.stream(
|
|
@@ -257,7 +269,7 @@ class FoundryBackend:
|
|
|
257
269
|
if tools:
|
|
258
270
|
payload["tools"] = tools
|
|
259
271
|
headers = {
|
|
260
|
-
"authorization": f"Bearer {self.
|
|
272
|
+
"authorization": f"Bearer {self._bearer()}",
|
|
261
273
|
"content-type": "application/json",
|
|
262
274
|
}
|
|
263
275
|
r = httpx.post(self._url, json=payload, headers=headers, timeout=_STREAM_TIMEOUT)
|
|
@@ -275,7 +287,7 @@ class FoundryBackend:
|
|
|
275
287
|
if tools:
|
|
276
288
|
payload["tools"] = tools
|
|
277
289
|
headers = {
|
|
278
|
-
"authorization": f"Bearer {self.
|
|
290
|
+
"authorization": f"Bearer {self._bearer()}",
|
|
279
291
|
"content-type": "application/json",
|
|
280
292
|
}
|
|
281
293
|
with httpx.stream(
|
|
@@ -285,8 +297,208 @@ class FoundryBackend:
|
|
|
285
297
|
return (yield from parse_sse_stream(r.iter_lines()))
|
|
286
298
|
|
|
287
299
|
|
|
300
|
+
class PaymentRequired(Exception):
|
|
301
|
+
"""The agent's wallet can't cover the call — the x402 rail returned HTTP 402.
|
|
302
|
+
|
|
303
|
+
Carries the parsed x402 envelope so callers can see the price + accepted schemes
|
|
304
|
+
and decide how to fund (top-up) or pay (X-PAYMENT)."""
|
|
305
|
+
|
|
306
|
+
def __init__(self, envelope: dict):
|
|
307
|
+
self.envelope = envelope or {}
|
|
308
|
+
accepts = self.envelope.get("accepts", [])
|
|
309
|
+
price = accepts[0].get("maxAmountRequired", "?") if accepts else "?"
|
|
310
|
+
schemes = ", ".join(a.get("scheme", "") for a in accepts) or "?"
|
|
311
|
+
super().__init__(
|
|
312
|
+
f"payment required: {price} micro-USD via [{schemes}] — fund the agent wallet"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class CroweGatewayBackend:
|
|
317
|
+
"""Foundry gateway PAID agent rail (/api/agent/v1/chat by default), Crowe ID auth.
|
|
318
|
+
|
|
319
|
+
Targets the x402 agent endpoint that debits the agent's wallet per call (override
|
|
320
|
+
with CROWE_AGENT_RESOURCE). Native GatewayResponse shape, not the OpenAI-compat
|
|
321
|
+
/v1 path (which 404s there); non-streaming with no server-side tool-calls, so we
|
|
322
|
+
adapt it to DeepParallel's streaming seam by yielding the full completion as a
|
|
323
|
+
single content chunk. The bearer is a Crowe ID client_credentials token from
|
|
324
|
+
``token_provider`` (the agent's sovereign identity).
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
label = "Crowe ID agent (Foundry gateway)"
|
|
328
|
+
|
|
329
|
+
def __init__(self, base_url: str, model: str, token_provider=None):
|
|
330
|
+
self._base_url = (base_url or "").rstrip("/")
|
|
331
|
+
self._model = model
|
|
332
|
+
self._token_provider = token_provider
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def _url(self) -> str:
|
|
336
|
+
# The PAID x402 agent rail (debits the agent's wallet). Overridable via
|
|
337
|
+
# CROWE_AGENT_RESOURCE for the legacy non-paid /api/gateway/chat path.
|
|
338
|
+
import os
|
|
339
|
+
|
|
340
|
+
resource = os.environ.get("CROWE_AGENT_RESOURCE", "/api/agent/v1/chat")
|
|
341
|
+
return f"{self._base_url}{resource}"
|
|
342
|
+
|
|
343
|
+
def _bearer(self) -> str:
|
|
344
|
+
return self._token_provider() if self._token_provider else ""
|
|
345
|
+
|
|
346
|
+
def _headers(self) -> dict:
|
|
347
|
+
return {
|
|
348
|
+
"authorization": f"Bearer {self._bearer()}",
|
|
349
|
+
"content-type": "application/json",
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
def check(self) -> tuple[bool, str]:
|
|
353
|
+
if not self._base_url or not self._token_provider:
|
|
354
|
+
return False, "Crowe gateway URL or Crowe ID credentials not configured."
|
|
355
|
+
try:
|
|
356
|
+
httpx.get(_host(self._base_url), timeout=_CHECK_TIMEOUT)
|
|
357
|
+
except Exception as e: # noqa: BLE001 - reachability probe
|
|
358
|
+
return False, f"Crowe gateway unreachable ({e.__class__.__name__})"
|
|
359
|
+
return True, f"Crowe ID @ {_host(self._base_url)}"
|
|
360
|
+
|
|
361
|
+
def _complete(self, messages, temperature, max_tokens) -> str:
|
|
362
|
+
payload = {
|
|
363
|
+
"model": self._model,
|
|
364
|
+
"messages": messages,
|
|
365
|
+
"temperature": temperature,
|
|
366
|
+
"max_tokens": max_tokens,
|
|
367
|
+
}
|
|
368
|
+
r = httpx.post(self._url, json=payload, headers=self._headers(), timeout=_STREAM_TIMEOUT)
|
|
369
|
+
if r.status_code == 402:
|
|
370
|
+
# x402 payment-required: surface the machine-readable envelope as an
|
|
371
|
+
# actionable error (price + schemes) rather than a raw HTTP error.
|
|
372
|
+
try:
|
|
373
|
+
envelope = r.json()
|
|
374
|
+
except Exception: # noqa: BLE001 - tolerate a non-JSON 402 body
|
|
375
|
+
envelope = {}
|
|
376
|
+
raise PaymentRequired(envelope)
|
|
377
|
+
r.raise_for_status()
|
|
378
|
+
return r.json().get("content", "")
|
|
379
|
+
|
|
380
|
+
def stream_chat(self, messages, temperature, max_tokens):
|
|
381
|
+
yield ("content", self._complete(messages, temperature, max_tokens))
|
|
382
|
+
|
|
383
|
+
def chat(self, messages, tools, temperature, max_tokens) -> dict:
|
|
384
|
+
# Native gateway endpoint has no tool-calling; tools are ignored.
|
|
385
|
+
return {"role": "assistant", "content": self._complete(messages, temperature, max_tokens)}
|
|
386
|
+
|
|
387
|
+
def stream_chat_tools(self, messages, tools, temperature, max_tokens):
|
|
388
|
+
# No server-side tool-calls; yields content and returns the final message
|
|
389
|
+
# (matches the FoundryBackend generator-return contract used by the agent loop).
|
|
390
|
+
content = self._complete(messages, temperature, max_tokens)
|
|
391
|
+
yield ("content", content)
|
|
392
|
+
return {"role": "assistant", "content": content}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class ModalBackend:
|
|
396
|
+
"""Gemma 4 Mycelium served on a Modal scale-to-zero GPU (the free base tier).
|
|
397
|
+
|
|
398
|
+
OpenAI-compatible /v1/chat/completions, but the Modal web endpoint requires
|
|
399
|
+
proxy-auth headers (Modal-Key / Modal-Secret) on every request — which is why
|
|
400
|
+
the gateway, not OWUI, must own this connection."""
|
|
401
|
+
|
|
402
|
+
label = "Modal (Mycelium)"
|
|
403
|
+
|
|
404
|
+
def __init__(self, endpoint: str, key: str, secret: str, model: str):
|
|
405
|
+
self._base_url = (endpoint or "").rstrip("/")
|
|
406
|
+
self._key = key or ""
|
|
407
|
+
self._secret = secret or ""
|
|
408
|
+
self._model = model
|
|
409
|
+
|
|
410
|
+
@property
|
|
411
|
+
def _url(self) -> str:
|
|
412
|
+
return f"{self._base_url}/v1/chat/completions"
|
|
413
|
+
|
|
414
|
+
def _headers(self) -> dict:
|
|
415
|
+
return {
|
|
416
|
+
"Modal-Key": self._key,
|
|
417
|
+
"Modal-Secret": self._secret,
|
|
418
|
+
"content-type": "application/json",
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
def check(self) -> tuple[bool, str]:
|
|
422
|
+
if not self._base_url or not self._key or not self._secret:
|
|
423
|
+
return False, "Modal Mycelium endpoint or proxy-auth token not configured."
|
|
424
|
+
try:
|
|
425
|
+
httpx.get(_host(self._base_url), timeout=_CHECK_TIMEOUT)
|
|
426
|
+
except Exception as e: # noqa: BLE001 - reachability probe
|
|
427
|
+
return False, f"Modal endpoint unreachable ({e.__class__.__name__})"
|
|
428
|
+
return True, f"Modal @ {_host(self._base_url)}"
|
|
429
|
+
|
|
430
|
+
def stream_chat(self, messages, temperature, max_tokens):
|
|
431
|
+
payload = {
|
|
432
|
+
"model": self._model,
|
|
433
|
+
"messages": messages,
|
|
434
|
+
"stream": True,
|
|
435
|
+
"temperature": temperature,
|
|
436
|
+
"max_tokens": max_tokens,
|
|
437
|
+
}
|
|
438
|
+
with httpx.stream(
|
|
439
|
+
"POST", self._url, json=payload, headers=self._headers(), timeout=_MODAL_TIMEOUT
|
|
440
|
+
) as r:
|
|
441
|
+
r.raise_for_status()
|
|
442
|
+
yield from parse_sse_lines(r.iter_lines())
|
|
443
|
+
|
|
444
|
+
def chat(self, messages, tools, temperature, max_tokens) -> dict:
|
|
445
|
+
payload = {
|
|
446
|
+
"model": self._model,
|
|
447
|
+
"messages": messages,
|
|
448
|
+
"stream": False,
|
|
449
|
+
"temperature": temperature,
|
|
450
|
+
"max_tokens": max_tokens,
|
|
451
|
+
}
|
|
452
|
+
if tools:
|
|
453
|
+
payload["tools"] = tools
|
|
454
|
+
r = httpx.post(self._url, json=payload, headers=self._headers(), timeout=_MODAL_TIMEOUT)
|
|
455
|
+
r.raise_for_status()
|
|
456
|
+
return _message_from_choice(r.json()["choices"][0])
|
|
457
|
+
|
|
458
|
+
def stream_chat_tools(self, messages, tools, temperature, max_tokens):
|
|
459
|
+
payload = {
|
|
460
|
+
"model": self._model,
|
|
461
|
+
"messages": messages,
|
|
462
|
+
"stream": True,
|
|
463
|
+
"temperature": temperature,
|
|
464
|
+
"max_tokens": max_tokens,
|
|
465
|
+
}
|
|
466
|
+
if tools:
|
|
467
|
+
payload["tools"] = tools
|
|
468
|
+
with httpx.stream(
|
|
469
|
+
"POST", self._url, json=payload, headers=self._headers(), timeout=_MODAL_TIMEOUT
|
|
470
|
+
) as r:
|
|
471
|
+
r.raise_for_status()
|
|
472
|
+
return (yield from parse_sse_stream(r.iter_lines()))
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
_crowe_providers: dict[tuple, crowe_id.CroweIDTokenProvider] = {}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _crowe_token_provider(settings) -> crowe_id.CroweIDTokenProvider:
|
|
479
|
+
"""One memoized token provider per (issuer, client_id) so fusion's many
|
|
480
|
+
per-deployment backends share a single cached Crowe ID token."""
|
|
481
|
+
key = (settings.crowe_id_issuer, settings.crowe_id_client_id)
|
|
482
|
+
provider = _crowe_providers.get(key)
|
|
483
|
+
if provider is None:
|
|
484
|
+
provider = crowe_id.CroweIDTokenProvider(
|
|
485
|
+
settings.crowe_id_issuer,
|
|
486
|
+
settings.crowe_id_client_id or "",
|
|
487
|
+
settings.crowe_id_client_secret or "",
|
|
488
|
+
audience=settings.crowe_id_audience,
|
|
489
|
+
)
|
|
490
|
+
_crowe_providers[key] = provider
|
|
491
|
+
return provider
|
|
492
|
+
|
|
493
|
+
|
|
288
494
|
def resolve_backend(settings) -> Backend:
|
|
289
495
|
"""Factory keyed on settings.backend."""
|
|
496
|
+
if settings.backend == "crowe":
|
|
497
|
+
return CroweGatewayBackend(
|
|
498
|
+
settings.gateway_url or "",
|
|
499
|
+
settings.foundry_model,
|
|
500
|
+
token_provider=_crowe_token_provider(settings),
|
|
501
|
+
)
|
|
290
502
|
if settings.backend == "foundry":
|
|
291
503
|
return FoundryBackend(
|
|
292
504
|
settings.foundry_base_url or "",
|
|
@@ -305,7 +517,22 @@ def backend_for_deployment(settings, deployment: str) -> Backend:
|
|
|
305
517
|
"""Build a backend targeting a specific deployment/model (for fusion).
|
|
306
518
|
|
|
307
519
|
Uses the same transport as the active backend, just a different model id.
|
|
520
|
+
The Modal-served Mycelium model is the exception: it routes to its own
|
|
521
|
+
endpoint with proxy-auth headers, regardless of the active backend.
|
|
308
522
|
"""
|
|
523
|
+
if settings.mycelium_endpoint and deployment == settings.mycelium_model:
|
|
524
|
+
return ModalBackend(
|
|
525
|
+
settings.mycelium_endpoint,
|
|
526
|
+
settings.mycelium_key or "",
|
|
527
|
+
settings.mycelium_secret or "",
|
|
528
|
+
deployment,
|
|
529
|
+
)
|
|
530
|
+
if settings.backend == "crowe":
|
|
531
|
+
return CroweGatewayBackend(
|
|
532
|
+
settings.gateway_url or "",
|
|
533
|
+
deployment,
|
|
534
|
+
token_provider=_crowe_token_provider(settings),
|
|
535
|
+
)
|
|
309
536
|
if settings.backend == "foundry":
|
|
310
537
|
return FoundryBackend(
|
|
311
538
|
settings.foundry_base_url or "", settings.foundry_api_key or "", deployment
|
|
@@ -111,34 +111,50 @@ class ThinkingSpinner:
|
|
|
111
111
|
self,
|
|
112
112
|
label: str = "thinking",
|
|
113
113
|
*,
|
|
114
|
-
lanes: int =
|
|
114
|
+
lanes: int = 20,
|
|
115
|
+
rows: int = 3,
|
|
115
116
|
speed: float = 0.45,
|
|
116
|
-
spread: float = 0.
|
|
117
|
+
spread: float = 0.45,
|
|
118
|
+
row_phase: float = 1.1,
|
|
117
119
|
hue_speed: float = 0.06,
|
|
118
120
|
):
|
|
119
121
|
self._label = label
|
|
120
122
|
self._lanes = lanes
|
|
123
|
+
self._rows = rows # parallel reasoning streams stacked into a field
|
|
121
124
|
self._speed = speed # radians advanced per frame (animation tempo)
|
|
122
125
|
self._spread = spread # phase offset between lanes (crest travel)
|
|
126
|
+
self._row_phase = row_phase # phase offset between streams (they desync)
|
|
123
127
|
self._hue_speed = hue_speed # palette stops advanced per frame (color drift)
|
|
124
128
|
self._tick = 0
|
|
125
129
|
|
|
126
130
|
def frame(self, tick: int) -> Text:
|
|
127
|
-
"""Build the frame at a given tick (pure; used for rendering + tests).
|
|
131
|
+
"""Build the frame at a given tick (pure; used for rendering + tests).
|
|
132
|
+
|
|
133
|
+
An amplified multi-row FIELD of parallel reasoning streams: each row is a
|
|
134
|
+
lane of pulse blocks with a bright crest travelling across it, the rows
|
|
135
|
+
desynced by `row_phase` so the field shimmers like many chains computing
|
|
136
|
+
at once. Row 0 carries the ◆ mark + label (so `.plain` starts with ◆)."""
|
|
128
137
|
text = Text()
|
|
129
138
|
crest = _crest_color(tick * self._hue_speed) # this frame's drifting hue
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
# Crest cells take the live hue; mid cells a dimmed blend; troughs go dim.
|
|
134
|
-
if level > 0.72:
|
|
135
|
-
style = f"bold {crest}"
|
|
136
|
-
elif level > 0.4:
|
|
137
|
-
style = _lerp_hex("#3a4a4a", crest, 0.6)
|
|
139
|
+
for r in range(self._rows):
|
|
140
|
+
if r == 0:
|
|
141
|
+
text.append(f"{MARK} ", style=f"bold {crest}")
|
|
138
142
|
else:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
143
|
+
text.append("\n ") # continuation streams indent under the mark
|
|
144
|
+
for i in range(self._lanes):
|
|
145
|
+
level = (
|
|
146
|
+
math.sin(tick * self._speed - i * self._spread - r * self._row_phase) + 1
|
|
147
|
+
) / 2 # 0..1
|
|
148
|
+
# Crest cells take the live hue; mid cells a dimmed blend; troughs dim.
|
|
149
|
+
if level > 0.72:
|
|
150
|
+
style = f"bold {crest}"
|
|
151
|
+
elif level > 0.4:
|
|
152
|
+
style = _lerp_hex("#3a4a4a", crest, 0.6)
|
|
153
|
+
else:
|
|
154
|
+
style = DIM
|
|
155
|
+
text.append(_PULSE_BLOCKS[round(level * (len(_PULSE_BLOCKS) - 1))], style=style)
|
|
156
|
+
if r == 0:
|
|
157
|
+
text.append(f" {self._label}…", style=DIM)
|
|
142
158
|
return text
|
|
143
159
|
|
|
144
160
|
def __rich__(self) -> Text:
|
|
@@ -187,7 +203,10 @@ def render_tool_card(
|
|
|
187
203
|
result: str = "",
|
|
188
204
|
duration_ms: int = 0,
|
|
189
205
|
) -> None:
|
|
190
|
-
"""Render a tool call as a running line, or a
|
|
206
|
+
"""Render a tool call as a running line, a one-line success, or a failure
|
|
207
|
+
panel. Success stays on a single line so a long agent turn reads as a tight
|
|
208
|
+
ledger of actions; only failures get a bordered block, so red weight means
|
|
209
|
+
something went wrong."""
|
|
191
210
|
if status == "running":
|
|
192
211
|
line = Text()
|
|
193
212
|
line.append(f"{ARROW} ", style=DP_ACCENT)
|
|
@@ -198,11 +217,20 @@ def render_tool_card(
|
|
|
198
217
|
console.print(_gutter(line))
|
|
199
218
|
return
|
|
200
219
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
220
|
+
if status == "ok":
|
|
221
|
+
line = Text()
|
|
222
|
+
line.append(f"{CHECK} ", style=GREEN_HEX)
|
|
223
|
+
line.append(name, style=f"bold {DP_ACCENT}")
|
|
224
|
+
line.append(f" {DOT} {duration_ms / 1000:.1f}s", style=DIM)
|
|
225
|
+
if args:
|
|
226
|
+
line.append(f" {DOT} {preview_tool_args(args)}", style=DIM)
|
|
227
|
+
if result:
|
|
228
|
+
line.append(f" {DOT} {preview_tool_args(result, limit=96)}", style=DIM)
|
|
229
|
+
console.print(_gutter(line))
|
|
230
|
+
return
|
|
231
|
+
|
|
204
232
|
header = Text()
|
|
205
|
-
header.append(f"{
|
|
233
|
+
header.append(f"{CROSS} ", style=RED_HEX)
|
|
206
234
|
header.append(name, style=f"bold {DP_ACCENT}")
|
|
207
235
|
header.append(f" {DOT} {duration_ms / 1000:.1f}s", style=DIM)
|
|
208
236
|
parts = [header]
|
|
@@ -211,7 +239,7 @@ def render_tool_card(
|
|
|
211
239
|
if result:
|
|
212
240
|
parts.append(Text(preview_tool_args(result, limit=200), style=WHITE_HEX))
|
|
213
241
|
group = Group(*parts) if len(parts) > 1 else header
|
|
214
|
-
console.print(_gutter(Panel(group, border_style=
|
|
242
|
+
console.print(_gutter(Panel(group, border_style=RED_HEX, box=box.ROUNDED)))
|
|
215
243
|
|
|
216
244
|
|
|
217
245
|
def render_error(console: Console, title: str, detail: str = "") -> None:
|
|
@@ -277,6 +305,64 @@ def compact_wordmark() -> str:
|
|
|
277
305
|
return f"[bold {DP_ACCENT}]{MARK} DeepParallel[/]"
|
|
278
306
|
|
|
279
307
|
|
|
308
|
+
def animate_wordmark(console, *, animate: bool = True) -> None:
|
|
309
|
+
"""Animated drop-in reveal of the DEEPPARALLEL block wordmark.
|
|
310
|
+
|
|
311
|
+
The rows land top-to-bottom - each landing row flashes bright before settling
|
|
312
|
+
to cyan - then the whole block shimmers once. Distinct motion from sibling
|
|
313
|
+
CLIs (vertical drop-in vs. horizontal sweep) while sharing the amplified feel.
|
|
314
|
+
|
|
315
|
+
Narrow panes use the compact one-line mark. `animate=False` (tests / non-tty)
|
|
316
|
+
prints the block statically - same glyphs, no real-time motion.
|
|
317
|
+
"""
|
|
318
|
+
rows = wordmark_lines()
|
|
319
|
+
if console.width < wordmark_width():
|
|
320
|
+
console.print(compact_wordmark(), highlight=False)
|
|
321
|
+
return
|
|
322
|
+
if not (animate and getattr(console, "is_terminal", False)):
|
|
323
|
+
for line in rows:
|
|
324
|
+
console.print(f"[{DP_ACCENT}]{line}[/]", highlight=False)
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
import time
|
|
328
|
+
|
|
329
|
+
from rich.live import Live
|
|
330
|
+
|
|
331
|
+
w = wordmark_width()
|
|
332
|
+
padded = [line.ljust(w) for line in rows]
|
|
333
|
+
n = len(padded)
|
|
334
|
+
|
|
335
|
+
def cascade(landed: int, flash: int | None) -> Text:
|
|
336
|
+
t = Text()
|
|
337
|
+
for i in range(landed):
|
|
338
|
+
style = "bold bright_white" if i == flash else f"bold {DP_ACCENT}"
|
|
339
|
+
t.append(padded[i], style=style)
|
|
340
|
+
if i < landed - 1:
|
|
341
|
+
t.append("\n")
|
|
342
|
+
return t
|
|
343
|
+
|
|
344
|
+
def shine(band: int) -> Text:
|
|
345
|
+
t = Text()
|
|
346
|
+
for i, line in enumerate(padded):
|
|
347
|
+
for ci, ch in enumerate(line):
|
|
348
|
+
style = "bold bright_white" if band <= ci < band + 6 else f"bold {DP_ACCENT}"
|
|
349
|
+
t.append(ch, style=style)
|
|
350
|
+
if i < n - 1:
|
|
351
|
+
t.append("\n")
|
|
352
|
+
return t
|
|
353
|
+
|
|
354
|
+
with Live(console=console, transient=False, refresh_per_second=120) as live:
|
|
355
|
+
for k in range(1, n + 1): # cascade: each row drops in and flashes white
|
|
356
|
+
live.update(cascade(k, k - 1))
|
|
357
|
+
time.sleep(0.09)
|
|
358
|
+
live.update(cascade(k, None)) # settle that row to cyan
|
|
359
|
+
time.sleep(0.05)
|
|
360
|
+
for band in range(-6, w + 7, 3): # bright shine sweeps across the block
|
|
361
|
+
live.update(shine(band))
|
|
362
|
+
time.sleep(0.012)
|
|
363
|
+
live.update(Text("\n".join(rows), style=f"bold {DP_ACCENT}")) # final settle
|
|
364
|
+
|
|
365
|
+
|
|
280
366
|
def status_text(
|
|
281
367
|
*, version: str, tool_count: int, fusion_modes: tuple[str, ...], backend_label: str
|
|
282
368
|
):
|
|
@@ -287,6 +373,7 @@ def status_text(
|
|
|
287
373
|
body.append(f" {DOT} v{version} {DOT} ", style=DIM)
|
|
288
374
|
body.append("served via Crowe Logic\n", style=f"bold {CROWE_ACCENT}")
|
|
289
375
|
body.append(f"{tool_count} tools", style=WHITE_HEX)
|
|
376
|
+
body.append(f" {DOT} 5,800+ MCP servers", style=DIM)
|
|
290
377
|
body.append(f" {DOT} fusion: {fusion}\n", style=DIM)
|
|
291
378
|
body.append("Backend: ", style=DIM)
|
|
292
379
|
body.append(f"{backend_label}\n\n", style="white")
|
|
@@ -39,6 +39,19 @@ class Settings:
|
|
|
39
39
|
judge_deployment: str
|
|
40
40
|
guardian_enabled: bool
|
|
41
41
|
guardian_deployment: str
|
|
42
|
+
# Modal-served Gemma 4 Mycelium (free base tier). Inert until the endpoint +
|
|
43
|
+
# proxy-auth token are set; routes through ModalBackend when configured.
|
|
44
|
+
mycelium_endpoint: str | None = None
|
|
45
|
+
mycelium_key: str | None = None
|
|
46
|
+
mycelium_secret: str | None = None
|
|
47
|
+
mycelium_model: str = "Mcrowe1210/gemma-4-mycelium-e4b"
|
|
48
|
+
# Crowe ID agent identity (backend="crowe"): route through the Foundry gateway
|
|
49
|
+
# authenticated by a client_credentials token instead of a raw provider key.
|
|
50
|
+
gateway_url: str | None = None
|
|
51
|
+
crowe_id_issuer: str = "https://id.crowelogic.com/realms/crowe"
|
|
52
|
+
crowe_id_client_id: str | None = None
|
|
53
|
+
crowe_id_client_secret: str | None = None
|
|
54
|
+
crowe_id_audience: str | None = None
|
|
42
55
|
|
|
43
56
|
|
|
44
57
|
@dataclass(frozen=True)
|
|
@@ -89,7 +102,7 @@ def _int_env(name: str, default: int) -> int:
|
|
|
89
102
|
|
|
90
103
|
def resolve_settings() -> Settings:
|
|
91
104
|
backend = os.environ.get("DEEPPARALLEL_BACKEND", "azure").strip().lower()
|
|
92
|
-
if backend not in {"azure", "foundry"}:
|
|
105
|
+
if backend not in {"azure", "foundry", "crowe"}:
|
|
93
106
|
backend = "azure"
|
|
94
107
|
return Settings(
|
|
95
108
|
backend=backend,
|
|
@@ -122,6 +135,20 @@ def resolve_settings() -> Settings:
|
|
|
122
135
|
# model spends a small token budget on hidden thinking and returns no
|
|
123
136
|
# verdict). Differs from the primary author model for a real 2nd opinion.
|
|
124
137
|
guardian_deployment=os.environ.get("DEEPPARALLEL_GUARDIAN_DEPLOYMENT", "DeepSeek-V4-Flash"),
|
|
138
|
+
mycelium_endpoint=os.environ.get("MODAL_MYCELIUM_ENDPOINT"),
|
|
139
|
+
mycelium_key=os.environ.get("MODAL_MYCELIUM_KEY"),
|
|
140
|
+
mycelium_secret=os.environ.get("MODAL_MYCELIUM_SECRET"),
|
|
141
|
+
mycelium_model=os.environ.get(
|
|
142
|
+
"DEEPPARALLEL_MYCELIUM_MODEL", "Mcrowe1210/gemma-4-mycelium-e4b"
|
|
143
|
+
),
|
|
144
|
+
gateway_url=os.environ.get(
|
|
145
|
+
"CROWE_LOGIC_GATEWAY_URL",
|
|
146
|
+
"https://foundry-control-plane-production.up.railway.app",
|
|
147
|
+
),
|
|
148
|
+
crowe_id_issuer=os.environ.get("CROWE_ID_ISSUER", "https://id.crowelogic.com/realms/crowe"),
|
|
149
|
+
crowe_id_client_id=os.environ.get("CROWE_ID_CLIENT_ID"),
|
|
150
|
+
crowe_id_client_secret=os.environ.get("CROWE_ID_CLIENT_SECRET"),
|
|
151
|
+
crowe_id_audience=os.environ.get("CROWE_ID_AUDIENCE"),
|
|
125
152
|
)
|
|
126
153
|
|
|
127
154
|
|
|
@@ -133,6 +160,13 @@ def missing_required(settings: Settings) -> list[str]:
|
|
|
133
160
|
missing.append("AZURE_CORE_ENDPOINT")
|
|
134
161
|
if not settings.azure_api_key:
|
|
135
162
|
missing.append("AZURE_CORE_API_KEY")
|
|
163
|
+
elif settings.backend == "crowe":
|
|
164
|
+
if not settings.gateway_url:
|
|
165
|
+
missing.append("CROWE_LOGIC_GATEWAY_URL")
|
|
166
|
+
if not settings.crowe_id_client_id:
|
|
167
|
+
missing.append("CROWE_ID_CLIENT_ID")
|
|
168
|
+
if not settings.crowe_id_client_secret:
|
|
169
|
+
missing.append("CROWE_ID_CLIENT_SECRET")
|
|
136
170
|
else:
|
|
137
171
|
if not settings.foundry_base_url:
|
|
138
172
|
missing.append("FOUNDRY_BASE_URL")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Crowe ID (Keycloak) OAuth2 client_credentials token provider.
|
|
2
|
+
|
|
3
|
+
Lets DeepParallel authenticate to the Crowe Logic Foundry gateway with a sovereign
|
|
4
|
+
*agent* identity instead of a raw provider API key: it mints a client_credentials
|
|
5
|
+
access token from Crowe ID, caches it until shortly before expiry, and hands it to
|
|
6
|
+
the Foundry/OpenAI-compat backend as the Bearer token. The only side effect is the
|
|
7
|
+
token POST (and its in-memory cache).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CroweIDError(Exception):
|
|
18
|
+
"""A Crowe ID token request failed or returned no usable access token."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CroweIDTokenProvider:
|
|
22
|
+
"""Mints + caches a client_credentials access token from Crowe ID.
|
|
23
|
+
|
|
24
|
+
Call ``token()`` (or the instance directly) to get a currently-valid bearer.
|
|
25
|
+
The token is refreshed automatically once it is within ``leeway`` seconds of
|
|
26
|
+
expiry so a long CLI session never hands the gateway a stale token.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
issuer: str,
|
|
32
|
+
client_id: str,
|
|
33
|
+
client_secret: str,
|
|
34
|
+
audience: str | None = None,
|
|
35
|
+
*,
|
|
36
|
+
leeway: int = 30,
|
|
37
|
+
timeout: int = 15,
|
|
38
|
+
):
|
|
39
|
+
self._token_url = f"{issuer.rstrip('/')}/protocol/openid-connect/token"
|
|
40
|
+
self._client_id = client_id
|
|
41
|
+
self._client_secret = client_secret
|
|
42
|
+
self._audience = audience
|
|
43
|
+
self._leeway = leeway
|
|
44
|
+
self._timeout = timeout
|
|
45
|
+
self._cached: tuple[str, float] | None = None # (token, expires_at_monotonic)
|
|
46
|
+
|
|
47
|
+
def token(self) -> str:
|
|
48
|
+
now = time.monotonic()
|
|
49
|
+
if self._cached and now < self._cached[1]:
|
|
50
|
+
return self._cached[0]
|
|
51
|
+
tok, ttl = self._fetch()
|
|
52
|
+
self._cached = (tok, now + max(0, ttl - self._leeway))
|
|
53
|
+
return tok
|
|
54
|
+
|
|
55
|
+
def __call__(self) -> str:
|
|
56
|
+
return self.token()
|
|
57
|
+
|
|
58
|
+
def _fetch(self) -> tuple[str, int]:
|
|
59
|
+
data = {
|
|
60
|
+
"grant_type": "client_credentials",
|
|
61
|
+
"client_id": self._client_id,
|
|
62
|
+
"client_secret": self._client_secret,
|
|
63
|
+
}
|
|
64
|
+
if self._audience:
|
|
65
|
+
data["audience"] = self._audience
|
|
66
|
+
try:
|
|
67
|
+
resp = httpx.post(self._token_url, data=data, timeout=self._timeout)
|
|
68
|
+
resp.raise_for_status()
|
|
69
|
+
body = resp.json()
|
|
70
|
+
except Exception as exc: # noqa: BLE001 - surface any transport/HTTP failure uniformly
|
|
71
|
+
raise CroweIDError(f"Crowe ID token request failed: {exc}")
|
|
72
|
+
tok = body.get("access_token")
|
|
73
|
+
if not tok:
|
|
74
|
+
raise CroweIDError("Crowe ID response missing access_token")
|
|
75
|
+
return tok, int(body.get("expires_in", 300))
|