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.
Files changed (77) hide show
  1. {deepparallel-0.4.3 → deepparallel-0.5.0}/PKG-INFO +1 -1
  2. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/__init__.py +1 -1
  3. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/backend.py +232 -5
  4. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/branding.py +107 -20
  5. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/config.py +35 -1
  6. deepparallel-0.5.0/deepparallel/crowe_id.py +75 -0
  7. deepparallel-0.5.0/deepparallel/dsml.py +129 -0
  8. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/renderer.py +4 -10
  9. deepparallel-0.5.0/deepparallel/research/provider.py +125 -0
  10. deepparallel-0.5.0/deepparallel/routing.example.json +32 -0
  11. deepparallel-0.5.0/deepparallel/routing.py +135 -0
  12. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/serve.py +105 -12
  13. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/__init__.py +1 -0
  14. deepparallel-0.5.0/deepparallel/tools/mcp.py +280 -0
  15. deepparallel-0.5.0/deepparallel/userinput.py +135 -0
  16. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel.egg-info/PKG-INFO +1 -1
  17. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel.egg-info/SOURCES.txt +17 -1
  18. {deepparallel-0.4.3 → deepparallel-0.5.0}/pyproject.toml +2 -2
  19. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_backend.py +60 -0
  20. deepparallel-0.5.0/tests/test_crowe_backend.py +79 -0
  21. deepparallel-0.5.0/tests/test_crowe_gateway_backend.py +99 -0
  22. deepparallel-0.5.0/tests/test_crowe_id_auth.py +112 -0
  23. deepparallel-0.5.0/tests/test_crowe_payment_required.py +70 -0
  24. deepparallel-0.5.0/tests/test_dsml.py +104 -0
  25. deepparallel-0.5.0/tests/test_research_provider.py +96 -0
  26. deepparallel-0.5.0/tests/test_routing.py +169 -0
  27. deepparallel-0.5.0/tests/test_serve.py +259 -0
  28. deepparallel-0.5.0/tests/test_tools_mcp.py +47 -0
  29. deepparallel-0.5.0/tests/test_userinput_paste.py +71 -0
  30. deepparallel-0.4.3/deepparallel/userinput.py +0 -61
  31. {deepparallel-0.4.3 → deepparallel-0.5.0}/README.md +0 -0
  32. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/agent.py +0 -0
  33. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/cli.py +0 -0
  34. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/fusion.py +0 -0
  35. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/licensing.py +0 -0
  36. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/registry.json +0 -0
  37. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/research/__init__.py +0 -0
  38. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/research/conduit.py +0 -0
  39. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/supply_chain.py +0 -0
  40. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/system_prompt.txt +0 -0
  41. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/codeast.py +0 -0
  42. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/edit.py +0 -0
  43. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/files.py +0 -0
  44. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/registry.py +0 -0
  45. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/sandbox.py +0 -0
  46. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/search.py +0 -0
  47. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/shell.py +0 -0
  48. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/vision.py +0 -0
  49. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel/tools/web.py +0 -0
  50. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel.egg-info/dependency_links.txt +0 -0
  51. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel.egg-info/entry_points.txt +0 -0
  52. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel.egg-info/requires.txt +0 -0
  53. {deepparallel-0.4.3 → deepparallel-0.5.0}/deepparallel.egg-info/top_level.txt +0 -0
  54. {deepparallel-0.4.3 → deepparallel-0.5.0}/setup.cfg +0 -0
  55. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_agent.py +0 -0
  56. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_backend_chat.py +0 -0
  57. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_backend_stream.py +0 -0
  58. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_branding.py +0 -0
  59. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_cli.py +0 -0
  60. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_config.py +0 -0
  61. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_fusion.py +0 -0
  62. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_issuer_signer.py +0 -0
  63. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_licensing.py +0 -0
  64. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_renderer.py +0 -0
  65. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_research.py +0 -0
  66. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_spinner_color.py +0 -0
  67. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_supply_chain.py +0 -0
  68. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tool_registry.py +0 -0
  69. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tools_codeast.py +0 -0
  70. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tools_edit.py +0 -0
  71. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tools_files.py +0 -0
  72. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tools_sandbox.py +0 -0
  73. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tools_search.py +0 -0
  74. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tools_shell.py +0 -0
  75. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tools_vision.py +0 -0
  76. {deepparallel-0.4.3 → deepparallel-0.5.0}/tests/test_tools_web.py +0 -0
  77. {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.4.3
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
@@ -1,3 +1,3 @@
1
1
  """DeepParallel CLI package."""
2
2
 
3
- __version__ = "0.4.3"
3
+ __version__ = "0.5.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._api_key}",
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._api_key}",
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._api_key}",
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 = 5,
114
+ lanes: int = 20,
115
+ rows: int = 3,
115
116
  speed: float = 0.45,
116
- spread: float = 0.7,
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
- text.append(f"{MARK} ", style=f"bold {crest}")
131
- for i in range(self._lanes):
132
- level = (math.sin(tick * self._speed - i * self._spread) + 1) / 2 # 0..1
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
- style = DIM
140
- text.append(_PULSE_BLOCKS[round(level * (len(_PULSE_BLOCKS) - 1))], style=style)
141
- text.append(f" {self._label}…", style=DIM)
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 done/failed panel."""
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
- ok = status == "ok"
202
- glyph = CHECK if ok else CROSS
203
- accent = GREEN_HEX if ok else RED_HEX
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"{glyph} ", style=accent)
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=accent, box=box.ROUNDED)))
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))