deepparallel 0.4.2__tar.gz → 0.4.3__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 (61) hide show
  1. {deepparallel-0.4.2 → deepparallel-0.4.3}/PKG-INFO +1 -1
  2. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/__init__.py +1 -1
  3. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/branding.py +98 -0
  4. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/cli.py +19 -2
  5. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/licensing.py +1 -1
  6. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/renderer.py +33 -5
  7. deepparallel-0.4.3/deepparallel/serve.py +261 -0
  8. deepparallel-0.4.3/deepparallel/system_prompt.txt +7 -0
  9. deepparallel-0.4.3/deepparallel/userinput.py +61 -0
  10. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel.egg-info/PKG-INFO +1 -1
  11. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel.egg-info/SOURCES.txt +5 -1
  12. {deepparallel-0.4.2 → deepparallel-0.4.3}/pyproject.toml +1 -1
  13. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_renderer.py +64 -2
  14. deepparallel-0.4.3/tests/test_spinner_color.py +45 -0
  15. deepparallel-0.4.3/tests/test_userinput.py +40 -0
  16. deepparallel-0.4.2/deepparallel/system_prompt.txt +0 -4
  17. {deepparallel-0.4.2 → deepparallel-0.4.3}/README.md +0 -0
  18. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/agent.py +0 -0
  19. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/backend.py +0 -0
  20. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/config.py +0 -0
  21. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/fusion.py +0 -0
  22. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/registry.json +0 -0
  23. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/research/__init__.py +0 -0
  24. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/research/conduit.py +0 -0
  25. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/supply_chain.py +0 -0
  26. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/tools/__init__.py +0 -0
  27. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/tools/codeast.py +0 -0
  28. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/tools/edit.py +0 -0
  29. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/tools/files.py +0 -0
  30. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/tools/registry.py +0 -0
  31. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/tools/sandbox.py +0 -0
  32. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/tools/search.py +0 -0
  33. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/tools/shell.py +0 -0
  34. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/tools/vision.py +0 -0
  35. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel/tools/web.py +0 -0
  36. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel.egg-info/dependency_links.txt +0 -0
  37. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel.egg-info/entry_points.txt +0 -0
  38. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel.egg-info/requires.txt +0 -0
  39. {deepparallel-0.4.2 → deepparallel-0.4.3}/deepparallel.egg-info/top_level.txt +0 -0
  40. {deepparallel-0.4.2 → deepparallel-0.4.3}/setup.cfg +0 -0
  41. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_agent.py +0 -0
  42. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_backend.py +0 -0
  43. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_backend_chat.py +0 -0
  44. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_backend_stream.py +0 -0
  45. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_branding.py +0 -0
  46. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_cli.py +0 -0
  47. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_config.py +0 -0
  48. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_fusion.py +0 -0
  49. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_issuer_signer.py +0 -0
  50. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_licensing.py +0 -0
  51. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_research.py +0 -0
  52. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_supply_chain.py +0 -0
  53. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_tool_registry.py +0 -0
  54. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_tools_codeast.py +0 -0
  55. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_tools_edit.py +0 -0
  56. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_tools_files.py +0 -0
  57. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_tools_sandbox.py +0 -0
  58. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_tools_search.py +0 -0
  59. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_tools_shell.py +0 -0
  60. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_tools_vision.py +0 -0
  61. {deepparallel-0.4.2 → deepparallel-0.4.3}/tests/test_tools_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepparallel
3
- Version: 0.4.2
3
+ Version: 0.4.3
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.2"
3
+ __version__ = "0.4.3"
@@ -9,6 +9,8 @@ status colors follow the Crowe Logic CLI family patterns.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import math
13
+
12
14
  from rich import box
13
15
  from rich.console import Console, Group
14
16
  from rich.markdown import Markdown
@@ -66,6 +68,91 @@ def thinking(text: str) -> None:
66
68
  console.print(f"[{DIM}]{text}[/]", end="", soft_wrap=True, highlight=False)
67
69
 
68
70
 
71
+ _PULSE_BLOCKS = "▁▂▃▄▅▆▇█"
72
+
73
+ # Crest color drifts slowly through these stops while the pulse travels. On-brand:
74
+ # DeepParallel cyan -> a cooler teal-blue -> Crowe green, then loops. The drift is
75
+ # what makes the spinner "change colors" as it works, distinct from the per-lane
76
+ # brightness that creates the travelling crest.
77
+ _CREST_PALETTE = ("#3fd0d0", "#5fb8c4", "#8fa4bf", "#79bf94", "#6fbf73")
78
+
79
+
80
+ def _lerp_hex(a: str, b: str, t: float) -> str:
81
+ """Linear blend between two #rrggbb colors; t in 0..1. Pure, for the crest drift."""
82
+ t = 0.0 if t < 0 else 1.0 if t > 1 else t
83
+ ar, ag, ab = int(a[1:3], 16), int(a[3:5], 16), int(a[5:7], 16)
84
+ br, bg, bb = int(b[1:3], 16), int(b[3:5], 16), int(b[5:7], 16)
85
+ r = round(ar + (br - ar) * t)
86
+ g = round(ag + (bg - ag) * t)
87
+ bl = round(ab + (bb - ab) * t)
88
+ return f"#{r:02x}{g:02x}{bl:02x}"
89
+
90
+
91
+ def _crest_color(phase: float) -> str:
92
+ """The crest hue at a continuous phase, cycling smoothly around _CREST_PALETTE."""
93
+ n = len(_CREST_PALETTE)
94
+ pos = (phase % n + n) % n # wrap into 0..n
95
+ i = int(pos)
96
+ return _lerp_hex(_CREST_PALETTE[i], _CREST_PALETTE[(i + 1) % n], pos - i)
97
+
98
+
99
+ class ThinkingSpinner:
100
+ """The 'working' animation in DeepParallel's idiom: several reasoning lanes
101
+ pulsing in parallel with a bright crest travelling across them, anchored by
102
+ the ◆ mark - parallel chains computing toward one answer, in the cyan
103
+ identity. Distinct from a borrowed braille dot.
104
+
105
+ Stateful: each render advances one frame. A rich `Live` widget animates it
106
+ simply by re-rendering at its refresh rate (the same mechanism the built-in
107
+ Spinner relies on), so no timer thread of our own is needed.
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ label: str = "thinking",
113
+ *,
114
+ lanes: int = 5,
115
+ speed: float = 0.45,
116
+ spread: float = 0.7,
117
+ hue_speed: float = 0.06,
118
+ ):
119
+ self._label = label
120
+ self._lanes = lanes
121
+ self._speed = speed # radians advanced per frame (animation tempo)
122
+ self._spread = spread # phase offset between lanes (crest travel)
123
+ self._hue_speed = hue_speed # palette stops advanced per frame (color drift)
124
+ self._tick = 0
125
+
126
+ def frame(self, tick: int) -> Text:
127
+ """Build the frame at a given tick (pure; used for rendering + tests)."""
128
+ text = Text()
129
+ 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)
138
+ 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)
142
+ return text
143
+
144
+ def __rich__(self) -> Text:
145
+ frame = self.frame(self._tick)
146
+ self._tick += 1
147
+ return frame
148
+
149
+
150
+ def thinking_spinner(label: str = "thinking") -> ThinkingSpinner:
151
+ """A fresh DeepParallel thinking animation (one per turn so it starts at
152
+ frame 0). Drive it inside a transient `rich.live.Live`."""
153
+ return ThinkingSpinner(label)
154
+
155
+
69
156
  def info(msg: str) -> None:
70
157
  console.print(f"[{DIM}]{msg}[/]")
71
158
 
@@ -179,6 +266,17 @@ def wordmark_lines() -> list[str]:
179
266
  return _WORDMARK.split("\n")
180
267
 
181
268
 
269
+ def wordmark_width() -> int:
270
+ """Column width of the block wordmark; below this it wraps and shatters."""
271
+ return max((len(line) for line in wordmark_lines()), default=0)
272
+
273
+
274
+ def compact_wordmark() -> str:
275
+ """A one-line banner for panes too narrow for the block wordmark. Rich
276
+ markup; always fits, so a split/narrow pane never shows shattered letters."""
277
+ return f"[bold {DP_ACCENT}]{MARK} DeepParallel[/]"
278
+
279
+
182
280
  def status_text(
183
281
  *, version: str, tool_count: int, fusion_modes: tuple[str, ...], backend_label: str
184
282
  ):
@@ -35,6 +35,7 @@ from deepparallel.agent import (
35
35
  )
36
36
  from deepparallel.backend import Backend, backend_for_deployment, resolve_backend
37
37
  from deepparallel.branding import console
38
+ from deepparallel.userinput import read_user_input
38
39
  from deepparallel.config import (
39
40
  Settings,
40
41
  _bool_env,
@@ -233,7 +234,7 @@ def _stream_repl(backend: Backend, settings: Settings) -> None:
233
234
  history: list[tuple[str, str]] = []
234
235
  while True:
235
236
  try:
236
- user_msg = console.input(branding.user_prefix() + "› ").strip()
237
+ user_msg = read_user_input()
237
238
  except (EOFError, KeyboardInterrupt):
238
239
  console.print()
239
240
  break
@@ -294,7 +295,7 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
294
295
  bits = ([mode] if mode != "off" else []) + (["auto"] if auto else [])
295
296
  tag = f"[{' · '.join(bits)}] " if bits else ""
296
297
  try:
297
- user_msg = console.input(f"{tag}{branding.user_prefix()}› ").strip()
298
+ user_msg = read_user_input(tag)
298
299
  except (EOFError, KeyboardInterrupt):
299
300
  console.print()
300
301
  break
@@ -521,6 +522,22 @@ def review(ctx: click.Context, as_diff: bool, path: str | None) -> None:
521
522
  sys.exit(verdict_exit_code(verdict))
522
523
 
523
524
 
525
+ @main.command()
526
+ @click.option("--host", default="127.0.0.1", help="Bind host.")
527
+ @click.option("--port", default=8013, type=int, help="Bind port.")
528
+ def serve(host: str, port: int) -> None:
529
+ """Run the OpenAI-compatible gateway for Crowe Terminal / Crowe Code, etc.
530
+
531
+ Exposes /v1/chat/completions + /v1/models so any OpenAI-format client can use
532
+ the Crowe Logic model stack through one endpoint, with the Crowe Logic persona
533
+ injected and raw-model identity leaks scrubbed. Select a model by deployment
534
+ name (see /v1/models); fusion models plug in here later.
535
+ """
536
+ from deepparallel.serve import run_server
537
+
538
+ run_server(host, port)
539
+
540
+
524
541
  @main.command()
525
542
  @click.argument("path", required=True)
526
543
  @click.pass_context
@@ -26,7 +26,7 @@ from pathlib import Path
26
26
 
27
27
  # Issuer public key (Ed25519, raw, base64). The matching private key is the
28
28
  # issuance secret and is never shipped.
29
- _EMBEDDED_PUBKEY = "I0YYNFwGGUZGD6h15leBEPBQ197Snik+njzxkVf/Owg="
29
+ _EMBEDDED_PUBKEY = "BdxSRIB5F2K2bXf7c0UqsR6jC/cSRMSxojpzRpTCUQg="
30
30
 
31
31
  # Paid features -> minimum tier required.
32
32
  _FEATURE_TIER: dict[str, "Tier"] = {}
@@ -15,7 +15,7 @@ from __future__ import annotations
15
15
  import sys
16
16
  import time
17
17
  from abc import ABC, abstractmethod
18
- from typing import Iterable
18
+ from typing import Iterable, Iterator
19
19
 
20
20
  from rich.console import Console
21
21
 
@@ -134,10 +134,15 @@ class RichRenderer(Renderer):
134
134
 
135
135
  def welcome(self, backend_label, *, version="", tool_count=0, fusion_modes=()) -> None:
136
136
  animate = self._console.is_terminal and _REVEAL_SECONDS > 0
137
- for line in branding.wordmark_lines():
138
- self._console.print(f"[{branding.DP_ACCENT}]{line}[/]", highlight=False)
139
- if animate:
140
- time.sleep(_REVEAL_SECONDS)
137
+ if self._console.width < branding.wordmark_width():
138
+ # Narrow / split pane: the block wordmark would wrap mid-letter and
139
+ # shatter. Use a compact one-line mark that always fits.
140
+ self._console.print(branding.compact_wordmark(), highlight=False)
141
+ else:
142
+ for line in branding.wordmark_lines():
143
+ self._console.print(f"[{branding.DP_ACCENT}]{line}[/]", highlight=False)
144
+ if animate:
145
+ time.sleep(_REVEAL_SECONDS)
141
146
  self._console.print()
142
147
  self._console.print(
143
148
  branding.status_text(
@@ -158,10 +163,33 @@ class RichRenderer(Renderer):
158
163
  never moves the cursor up, so it cannot ghost or stack the way a growing
159
164
  Live region does - it works the same on any terminal and at any answer
160
165
  length. On a pipe / non-tty, fall back to raw inline streaming."""
166
+ chunks = self._spin_until_first(chunks)
161
167
  if self._console.is_terminal:
162
168
  return self._stream_markdown_blocks(chunks)
163
169
  return self._stream_inline(chunks)
164
170
 
171
+ def _spin_until_first(self, chunks: Iterable[str]) -> Iterator[str]:
172
+ """Show a transient 'thinking' spinner during the wait for the first
173
+ token, then yield the stream through unchanged. The spinner is stopped
174
+ and cleared (transient=True) before any answer text prints, so unlike a
175
+ growing Live region it can never overlap or stack with the answer."""
176
+ it = iter(chunks)
177
+ if not self._console.is_terminal:
178
+ yield from it
179
+ return
180
+ from rich.live import Live
181
+
182
+ spinner = branding.thinking_spinner()
183
+ first = None
184
+ with Live(spinner, console=self._console, transient=True, refresh_per_second=12):
185
+ for c in it:
186
+ first = c
187
+ break
188
+ if first is None:
189
+ return # empty stream (e.g. a tool-only turn): nothing to reveal
190
+ yield first
191
+ yield from it
192
+
165
193
  def _stream_inline(self, chunks: Iterable[str]) -> str:
166
194
  # Raw token streaming for pipes / non-tty: no Live, never ghosts. The
167
195
  # marker is printed only on the first VISIBLE character, so empty /
@@ -0,0 +1,261 @@
1
+ # Copyright 2026, Crowe Logic Inc.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """OpenAI-compatible gateway for DeepParallel.
5
+
6
+ `deepparallel serve` exposes /v1/chat/completions and /v1/models so any
7
+ OpenAI-format client (Crowe Terminal / Crowe Code, the Vercel AI SDK, etc.) can
8
+ talk to the Crowe Logic model stack through one endpoint. The gateway is where
9
+ the models are made to *act as Crowe Logic*:
10
+
11
+ * persona — a Crowe Logic system prompt is injected when the caller sends none;
12
+ * scrub — raw-model identity leaks ("DeepSeek", deployment names) are rewritten
13
+ to CroweLM / Crowe Logic in the streamed output.
14
+
15
+ v1 is passthrough: `model` selects an Azure deployment by name and we proxy its
16
+ stream. Fusion models (deepparallel-deep / -reason) plug in here later by routing
17
+ to the fusion stack instead of a single deployment — the wire contract and the
18
+ persona/scrub layer stay identical, so the IDE never has to change.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import re
25
+ import time
26
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
27
+
28
+ from deepparallel.backend import backend_for_deployment
29
+ from deepparallel.config import load_system_prompt, resolve_settings
30
+
31
+ # Identity-leak scrub: the raw model occasionally self-identifies as DeepSeek
32
+ # despite the persona prompt. Rewrite those surfaces so the assistant reads as
33
+ # Crowe Logic. Order matters — specific deployment names before the bare brand.
34
+ _SCRUB: list[tuple[re.Pattern[str], str]] = [
35
+ (re.compile(r"DeepSeek[-\s]?V4[-\s]?Pro", re.I), "CroweLM Apex"),
36
+ (re.compile(r"DeepSeek[-\s]?V4[-\s]?Flash", re.I), "CroweLM Flash"),
37
+ (re.compile(r"DeepSeek[-\s]?R1[-\w]*", re.I), "CroweLM Reason"),
38
+ (re.compile(r"\bDeepSeek\b", re.I), "CroweLM"),
39
+ ]
40
+
41
+
42
+ # Public model ids (what the IDE shows / sends) -> Azure deployment (internal).
43
+ # The listing must never leak raw deployment names; chat accepts either the
44
+ # alias or the raw name, so existing callers keep working.
45
+ _MODEL_ALIASES: dict[str, str] = {
46
+ "crowelm-apex": "DeepSeek-V4-Pro",
47
+ "crowelm-reason": "DeepSeek-R1-0528",
48
+ "crowelm-flash": "DeepSeek-V4-Flash",
49
+ }
50
+
51
+
52
+ def _resolve_deployment(model: str, settings) -> str:
53
+ """Map a public model id to its Azure deployment, passing through raw names."""
54
+ return _MODEL_ALIASES.get(model, model or settings.deployment)
55
+
56
+
57
+ def scrub(text: str) -> str:
58
+ """Rewrite raw-model identity leaks to the Crowe Logic brand."""
59
+ if not text:
60
+ return text
61
+ for pattern, repl in _SCRUB:
62
+ text = pattern.sub(repl, text)
63
+ return text
64
+
65
+
66
+ def collapse_spacing(text: str) -> str:
67
+ """Collapse runs of 3+ newlines to a single blank line, and trim trailing
68
+ spaces before a newline. Raw models often over-emit blank lines, which reads
69
+ as double-spaced output in the chat; this normalizes it on the way out."""
70
+ text = re.sub(r"[ \t]+\n", "\n", text)
71
+ return re.sub(r"\n{3,}", "\n\n", text)
72
+
73
+
74
+ class _ContentShaper:
75
+ """Smooths streamed content delivery. It (1) re-chunks token bursts to word
76
+ boundaries so the client renders a steady word-by-word reveal, and (2)
77
+ collapses blank-line runs across chunk boundaries (spacing). Holds back only
78
+ the trailing partial word until the next chunk; flush() drains the rest."""
79
+
80
+ def __init__(self):
81
+ self._buf = ""
82
+
83
+ def feed(self, text: str) -> list[str]:
84
+ self._buf += text
85
+ boundary = max(self._buf.rfind(" "), self._buf.rfind("\n"))
86
+ if boundary == -1:
87
+ return [] # no complete word yet — keep buffering
88
+ ready, self._buf = self._buf[: boundary + 1], self._buf[boundary + 1 :]
89
+ return self._tokens(ready)
90
+
91
+ def flush(self) -> list[str]:
92
+ ready, self._buf = self._buf, ""
93
+ return self._tokens(ready)
94
+
95
+ def _tokens(self, ready: str) -> list[str]:
96
+ ready = collapse_spacing(ready)
97
+ return re.findall(r"\S+\s*|\s+", ready) if ready else []
98
+
99
+
100
+ def _ensure_persona(messages: list[dict]) -> list[dict]:
101
+ """Inject the Crowe Logic persona as a system message when none is present,
102
+ so a bare client request still answers in-brand."""
103
+ if any(m.get("role") == "system" for m in messages):
104
+ return messages
105
+ return [{"role": "system", "content": load_system_prompt()}, *messages]
106
+
107
+
108
+ def _models_payload(settings) -> dict:
109
+ deployments = set(settings.parallel_deployments or (settings.deployment,))
110
+ aliased = set(_MODEL_ALIASES.values())
111
+ # Prefer branded aliases; never list a raw deployment name that has one.
112
+ ids = [pub for pub, dep in _MODEL_ALIASES.items() if dep in deployments]
113
+ ids += [dep for dep in deployments if dep not in aliased]
114
+ created = int(time.time())
115
+ return {
116
+ "object": "list",
117
+ "data": [
118
+ {"id": i, "object": "model", "created": created, "owned_by": "crowe-logic"} for i in ids
119
+ ],
120
+ }
121
+
122
+
123
+ def _chunk(
124
+ model: str,
125
+ *,
126
+ content: str | None = None,
127
+ reasoning: str | None = None,
128
+ finish: str | None = None,
129
+ ) -> str:
130
+ delta: dict = {}
131
+ if content is not None:
132
+ delta["content"] = content
133
+ if reasoning is not None:
134
+ # reasoning_content is the OpenAI/Azure convention the chat clients read
135
+ # into their "thinking" channel — so the reasoning shows in the IDE.
136
+ delta["reasoning_content"] = reasoning
137
+ body = {
138
+ "id": "dp-" + str(int(time.time() * 1000)),
139
+ "object": "chat.completion.chunk",
140
+ "created": int(time.time()),
141
+ "model": model,
142
+ "choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
143
+ }
144
+ return "data: " + json.dumps(body) + "\n\n"
145
+
146
+
147
+ def _make_handler(settings):
148
+ class Handler(BaseHTTPRequestHandler):
149
+ protocol_version = "HTTP/1.1"
150
+
151
+ def log_message(self, *_args): # quiet default logging
152
+ pass
153
+
154
+ def _json(self, code: int, obj: dict) -> None:
155
+ payload = json.dumps(obj).encode()
156
+ self.send_response(code)
157
+ self.send_header("Content-Type", "application/json")
158
+ self.send_header("Content-Length", str(len(payload)))
159
+ self.end_headers()
160
+ self.wfile.write(payload)
161
+
162
+ def do_GET(self):
163
+ if self.path.rstrip("/") == "/v1/models":
164
+ self._json(200, _models_payload(settings))
165
+ return
166
+ if self.path.rstrip("/") in ("/health", "/healthz"):
167
+ self._json(200, {"status": "ok"})
168
+ return
169
+ self._json(404, {"error": {"message": "not found"}})
170
+
171
+ def do_POST(self):
172
+ if self.path.rstrip("/") != "/v1/chat/completions":
173
+ self._json(404, {"error": {"message": "not found"}})
174
+ return
175
+ length = int(self.headers.get("Content-Length", 0))
176
+ try:
177
+ req = json.loads(self.rfile.read(length) or b"{}")
178
+ except json.JSONDecodeError:
179
+ self._json(400, {"error": {"message": "invalid JSON body"}})
180
+ return
181
+
182
+ model = req.get("model") or "crowelm-apex"
183
+ deployment = _resolve_deployment(model, settings)
184
+ messages = _ensure_persona(list(req.get("messages", [])))
185
+ temperature = req.get("temperature", 0.7)
186
+ max_tokens = req.get("max_tokens", 4096)
187
+ backend = backend_for_deployment(settings, deployment)
188
+
189
+ if req.get("stream"):
190
+ self._stream(backend, model, messages, temperature, max_tokens)
191
+ else:
192
+ self._complete(backend, model, messages, temperature, max_tokens)
193
+
194
+ def _stream(self, backend, model, messages, temperature, max_tokens):
195
+ self.send_response(200)
196
+ self.send_header("Content-Type", "text/event-stream")
197
+ self.send_header("Cache-Control", "no-cache")
198
+ self.send_header("Connection", "keep-alive")
199
+ self.end_headers()
200
+ shaper = _ContentShaper()
201
+ try:
202
+ for channel, text in backend.stream_chat(messages, temperature, max_tokens):
203
+ if channel == "thinking":
204
+ # reasoning passes through verbatim (scrubbed) so the IDE
205
+ # can render the trace as it arrives.
206
+ self._send(_chunk(model, reasoning=scrub(text)))
207
+ continue
208
+ for word in shaper.feed(scrub(text)):
209
+ self._send(_chunk(model, content=word))
210
+ for word in shaper.flush():
211
+ self._send(_chunk(model, content=word))
212
+ self._send(_chunk(model, finish="stop"))
213
+ self.wfile.write(b"data: [DONE]\n\n")
214
+ self.wfile.flush()
215
+ except BrokenPipeError:
216
+ pass # client disconnected mid-stream
217
+
218
+ def _send(self, frame: str) -> None:
219
+ self.wfile.write(frame.encode())
220
+ self.wfile.flush()
221
+
222
+ def _complete(self, backend, model, messages, temperature, max_tokens):
223
+ # backend.chat returns the assistant message dict directly
224
+ # ({"role","content",...}), not a wrapper.
225
+ result = backend.chat(messages, None, temperature, max_tokens)
226
+ content = (
227
+ collapse_spacing(scrub((result or {}).get("content") or ""))
228
+ if isinstance(result, dict)
229
+ else ""
230
+ )
231
+ self._json(
232
+ 200,
233
+ {
234
+ "id": "dp-" + str(int(time.time() * 1000)),
235
+ "object": "chat.completion",
236
+ "created": int(time.time()),
237
+ "model": model,
238
+ "choices": [
239
+ {
240
+ "index": 0,
241
+ "message": {"role": "assistant", "content": content},
242
+ "finish_reason": "stop",
243
+ }
244
+ ],
245
+ },
246
+ )
247
+
248
+ return Handler
249
+
250
+
251
+ def run_server(host: str = "127.0.0.1", port: int = 8013) -> None:
252
+ settings = resolve_settings()
253
+ httpd = ThreadingHTTPServer((host, port), _make_handler(settings))
254
+ print(
255
+ f"DeepParallel gateway on http://{host}:{port}/v1 (model: {settings.deployment}, backend: {settings.backend})"
256
+ )
257
+ try:
258
+ httpd.serve_forever()
259
+ except KeyboardInterrupt:
260
+ print("\nshutting down")
261
+ httpd.shutdown()
@@ -0,0 +1,7 @@
1
+ You are DeepParallel, a precise coding assistant from Crowe Logic.
2
+
3
+ Voice: direct and concise. Lead with the answer, not a preamble — never open with "I'd be happy to", "Great question", or by restating the request. Give depth only when asked or when the problem genuinely needs it. Stop when the answer is complete; don't pad with summaries or follow-up offers unless they add real value.
4
+
5
+ Formatting: clean Markdown. Single blank lines between paragraphs — never double. Fenced code blocks with a language tag for code, inline backticks for identifiers and paths, tight lists (no blank line between items). Prefer a short prose answer over a bulleted list when a sentence will do.
6
+
7
+ You can use tools to read, search, analyze, edit, open, and run code. Use them when they help; do not call them speculatively. When the user asks to "open" a file (an HTML report, image, PDF, or folder) for viewing, use open_path to launch it in the default app rather than read_file, which only returns text. When asked to run something with different parameters, prefer non-destructive approaches (CLI arguments, environment variables, or a temporary copy) over editing the user's source files. Only edit a source file when changing it is the actual goal, and explain what you changed.
@@ -0,0 +1,61 @@
1
+ """Interactive prompt input.
2
+
3
+ The REPL reads user turns through prompt_toolkit rather than the cooked
4
+ `input()` / `console.input()` line discipline. This matters for robustness:
5
+ `input()` reads whatever lands in the terminal's line buffer, so when another
6
+ process writes to the same TTY (a background agent, a split pane), that stray
7
+ output gets echoed into the buffer and read as if the user typed it - the REPL
8
+ then "answers" the noise. prompt_toolkit puts the terminal in raw mode and reads
9
+ key events directly, rendering and redrawing its own prompt line, so background
10
+ writes scroll above the prompt instead of becoming input.
11
+
12
+ Falls back to plain `input()` when stdin/stdout is not a TTY (pipes, tests),
13
+ where a full-screen line editor can't run anyway.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ from html import escape
20
+ from pathlib import Path
21
+
22
+ from deepparallel import branding
23
+
24
+ _HISTORY_PATH = Path.home() / ".deepparallel_history"
25
+ _session = None # lazily built PromptSession (None until first interactive read)
26
+
27
+
28
+ def _get_session():
29
+ global _session
30
+ if _session is None:
31
+ from prompt_toolkit import PromptSession
32
+ from prompt_toolkit.history import FileHistory
33
+
34
+ _session = PromptSession(history=FileHistory(str(_HISTORY_PATH)))
35
+ return _session
36
+
37
+
38
+ def _prompt_html(tag: str):
39
+ """Build the prompt as prompt_toolkit markup: an optional dim mode tag
40
+ (e.g. '[reason · auto] ') followed by the green 'you ›' prefix."""
41
+ from prompt_toolkit.formatted_text import HTML
42
+
43
+ tag_part = f'<style fg="ansibrightblack">{escape(tag)}</style>' if tag else ""
44
+ return HTML(f'{tag_part}<b><style fg="ansibrightgreen">you</style></b> › ')
45
+
46
+
47
+ def _interactive() -> bool:
48
+ return sys.stdin.isatty() and sys.stdout.isatty()
49
+
50
+
51
+ def read_user_input(tag: str = "") -> str:
52
+ """Read one line from the user. `tag` is an optional prefix shown dimmed
53
+ before the prompt (the active mode chips). Returns the stripped line.
54
+
55
+ Raises EOFError on Ctrl-D and KeyboardInterrupt on Ctrl-C, matching the
56
+ contract the REPL loops already handle.
57
+ """
58
+ if not _interactive():
59
+ # Non-interactive: a raw-mode editor can't run. Mirror the old prompt.
60
+ return input(f"{tag}you {branding.ARROW} ").strip()
61
+ return _get_session().prompt(_prompt_html(tag)).strip()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepparallel
3
- Version: 0.4.2
3
+ Version: 0.4.3
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
@@ -10,8 +10,10 @@ deepparallel/fusion.py
10
10
  deepparallel/licensing.py
11
11
  deepparallel/registry.json
12
12
  deepparallel/renderer.py
13
+ deepparallel/serve.py
13
14
  deepparallel/supply_chain.py
14
15
  deepparallel/system_prompt.txt
16
+ deepparallel/userinput.py
15
17
  deepparallel.egg-info/PKG-INFO
16
18
  deepparallel.egg-info/SOURCES.txt
17
19
  deepparallel.egg-info/dependency_links.txt
@@ -42,6 +44,7 @@ tests/test_issuer_signer.py
42
44
  tests/test_licensing.py
43
45
  tests/test_renderer.py
44
46
  tests/test_research.py
47
+ tests/test_spinner_color.py
45
48
  tests/test_supply_chain.py
46
49
  tests/test_tool_registry.py
47
50
  tests/test_tools_codeast.py
@@ -51,4 +54,5 @@ tests/test_tools_sandbox.py
51
54
  tests/test_tools_search.py
52
55
  tests/test_tools_shell.py
53
56
  tests/test_tools_vision.py
54
- tests/test_tools_web.py
57
+ tests/test_tools_web.py
58
+ tests/test_userinput.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "deepparallel"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  description = "DeepParallel - a multi-model agentic coding CLI with cross-model Guardian review, served via Crowe Logic."
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -86,6 +86,23 @@ def test_rich_welcome_renders_wordmark_and_status(monkeypatch):
86
86
  assert "DeepSeek" not in out
87
87
 
88
88
 
89
+ def test_rich_welcome_narrow_pane_uses_compact_banner(monkeypatch):
90
+ import deepparallel.renderer as rmod
91
+
92
+ monkeypatch.setattr(rmod, "_REVEAL_SECONDS", 0)
93
+ buf = io.StringIO()
94
+ # narrower than the 71-col block wordmark: it would wrap and shatter
95
+ con = Console(no_color=True, width=46, file=buf, force_terminal=True, highlight=False)
96
+ rmod.RichRenderer(console=con).welcome("Azure @ https://x", version="0.4.2", tool_count=15)
97
+ out = buf.getvalue()
98
+ assert "◆ DeepParallel" in out # compact one-line mark
99
+ # the block wordmark's first row must NOT appear (no shattered letters)
100
+ assert "████ █████ █████" not in out
101
+ # no line exceeds the pane width (nothing wraps/shatters)
102
+ visible = [line for line in out.replace("\x1b[1m", "").replace("\x1b[0m", "").split("\n")]
103
+ assert max((len(line) for line in visible), default=0) <= 46
104
+
105
+
89
106
  def test_rich_tool_timer_path_renders_card(monkeypatch):
90
107
  buf = io.StringIO()
91
108
  con = Console(no_color=True, width=80, file=buf, force_terminal=True, highlight=False)
@@ -160,13 +177,17 @@ def test_rich_answer_stream_renders_markdown_formatting():
160
177
  assert "# Big" not in out
161
178
 
162
179
 
163
- def test_rich_answer_stream_empty_prints_nothing():
180
+ def test_rich_answer_stream_empty_renders_no_answer_panel():
181
+ # A tool-only turn (empty content stream) shows only the transient thinking
182
+ # spinner, which clears itself - it must not render an answer body and must
183
+ # leave nothing permanent (Live transient erases its line on exit).
164
184
  buf = io.StringIO()
165
185
  con = Console(no_color=True, width=80, file=buf, force_terminal=True, highlight=False)
166
186
  r = RichRenderer(console=con)
167
187
  full = r.answer_stream(iter([]))
168
188
  assert full == ""
169
- assert buf.getvalue() == "" # tool-only turns never start the live panel
189
+ out = buf.getvalue()
190
+ assert "\x1b[2K" in out or out == "" # transient spinner erased its own line
170
191
 
171
192
 
172
193
  def test_rich_answer_stream_whitespace_leading_holds_panel():
@@ -237,3 +258,44 @@ def test_fake_confirm_scripted():
237
258
  assert f.confirm("a", "d") is True
238
259
  assert f.confirm("b", "d") is False
239
260
  assert ("confirm", "a", "d") in f.calls
261
+
262
+
263
+ # ---------------------------------------------------- answer_stream + spinner
264
+ def test_rich_answer_stream_returns_full_text_with_spinner_path():
265
+ # forced terminal -> the transient thinking-spinner path runs; it must not
266
+ # corrupt the returned text or swallow content.
267
+ buf = io.StringIO()
268
+ con = Console(no_color=True, width=80, file=buf, force_terminal=True, highlight=False)
269
+ r = RichRenderer(console=con)
270
+ full = r.answer_stream(iter(["Hello ", "world", "\n\ndone"]))
271
+ assert full == "Hello world\n\ndone"
272
+ out = buf.getvalue()
273
+ assert "Hello world" in out and "done" in out
274
+
275
+
276
+ def test_rich_answer_stream_non_terminal_shows_no_spinner():
277
+ r, buf = _rich() # StringIO console: is_terminal is False
278
+ full = r.answer_stream(iter(["a", "b", "c"]))
279
+ assert full == "abc"
280
+ assert "thinking" not in buf.getvalue()
281
+
282
+
283
+ def test_thinking_spinner_animates_and_is_branded():
284
+ from deepparallel import branding
285
+
286
+ sp = branding.thinking_spinner("reasoning")
287
+ f0 = sp.frame(0).plain
288
+ f3 = sp.frame(3).plain
289
+ assert f0.startswith(branding.MARK) # anchored by the diamond mark
290
+ assert "reasoning…" in f0
291
+ assert any(b in f0 for b in branding._PULSE_BLOCKS) # parallel lanes present
292
+ assert f0 != f3 # the crest travels: distinct frames over time
293
+
294
+
295
+ def test_thinking_spinner_rich_protocol_advances_frame():
296
+ from deepparallel import branding
297
+
298
+ sp = branding.thinking_spinner()
299
+ a = sp.__rich__().plain
300
+ b = sp.__rich__().plain
301
+ assert a != b # each render is a new frame (Live animates by re-rendering)
@@ -0,0 +1,45 @@
1
+ """The thinking spinner drifts its crest color over time while keeping the
2
+ travelling-pulse motion. These tests pin the color-drift behavior without needing
3
+ a live terminal, since `frame()` is pure."""
4
+
5
+ from deepparallel import branding
6
+
7
+
8
+ def _crest_style(text):
9
+ """The ◆ mark is always the live crest hue; return its style string."""
10
+ # first span covers the "◆ " mark
11
+ return str(text.spans[0].style)
12
+
13
+
14
+ def test_crest_color_drifts_across_ticks():
15
+ sp = branding.thinking_spinner()
16
+ early = _crest_style(sp.frame(0))
17
+ later = _crest_style(sp.frame(40))
18
+ assert early != later, "crest hue should change as the spinner works"
19
+
20
+
21
+ def test_crest_color_cycles_back():
22
+ # The continuous hue function loops every len(_CREST_PALETTE) phase units.
23
+ # (The spinner samples this at discrete ticks, which need not divide evenly,
24
+ # so the invariant lives on the pure function, not the integer-tick frame.)
25
+ n = len(branding._CREST_PALETTE)
26
+ assert branding._crest_color(0.0) == branding._crest_color(float(n))
27
+ assert branding._crest_color(1.3) == branding._crest_color(1.3 + n)
28
+
29
+
30
+ def test_frame_is_pure():
31
+ sp = branding.thinking_spinner()
32
+ assert str(sp.frame(7)) == str(sp.frame(7)) # same tick -> identical frame
33
+
34
+
35
+ def test_lerp_hex_endpoints_and_midpoint():
36
+ assert branding._lerp_hex("#000000", "#ffffff", 0.0) == "#000000"
37
+ assert branding._lerp_hex("#000000", "#ffffff", 1.0) == "#ffffff"
38
+ assert branding._lerp_hex("#000000", "#ffffff", 0.5) == "#808080"
39
+
40
+
41
+ def test_crest_color_always_valid_hex():
42
+ for tick in range(0, 200, 3):
43
+ c = branding._crest_color(tick * 0.06)
44
+ assert c.startswith("#") and len(c) == 7
45
+ int(c[1:], 16) # parses as hex
@@ -0,0 +1,40 @@
1
+ import builtins
2
+
3
+ from deepparallel import userinput
4
+
5
+
6
+ def test_non_interactive_falls_back_to_input_and_strips(monkeypatch):
7
+ monkeypatch.setattr(userinput, "_interactive", lambda: False)
8
+ monkeypatch.setattr(builtins, "input", lambda prompt="": " hello there ")
9
+ assert userinput.read_user_input() == "hello there"
10
+
11
+
12
+ def test_non_interactive_prompt_includes_tag(monkeypatch):
13
+ captured = {}
14
+
15
+ def fake_input(prompt=""):
16
+ captured["prompt"] = prompt
17
+ return "do the thing\n"
18
+
19
+ monkeypatch.setattr(userinput, "_interactive", lambda: False)
20
+ monkeypatch.setattr(builtins, "input", fake_input)
21
+ out = userinput.read_user_input("[reason · auto] ")
22
+ assert out == "do the thing"
23
+ assert "[reason · auto]" in captured["prompt"]
24
+
25
+
26
+ def test_interactive_path_uses_prompt_session(monkeypatch):
27
+ class FakeSession:
28
+ def prompt(self, _formatted):
29
+ return " typed input "
30
+
31
+ monkeypatch.setattr(userinput, "_interactive", lambda: True)
32
+ monkeypatch.setattr(userinput, "_get_session", lambda: FakeSession())
33
+ assert userinput.read_user_input("[deep] ") == "typed input"
34
+
35
+
36
+ def test_prompt_html_escapes_tag(monkeypatch):
37
+ # a tag must not be able to inject prompt_toolkit markup
38
+ html = userinput._prompt_html("<b>x</b> ")
39
+ assert "&lt;b&gt;" in html.value
40
+ assert "you" in html.value
@@ -1,4 +0,0 @@
1
- You are DeepParallel, a precise and capable coding assistant served via Crowe Logic.
2
- Answer clearly and directly. When a problem benefits from step-by-step reasoning, reason carefully before giving the final answer. Be concise unless asked for depth.
3
-
4
- You can use tools to read, search, analyze, edit, open, and run code. Use them when they help; do not call them speculatively. When the user asks to "open" a file (an HTML report, image, PDF, or folder) for viewing, use open_path to launch it in the default app rather than read_file, which only returns text contents. When asked to run something with different parameters, prefer non-destructive approaches (command-line arguments, environment variables, or a temporary copy) over editing the user's source files. Only edit a source file when changing that file is the actual goal, and explain what you changed.
File without changes
File without changes