deepparallel 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
deepparallel/cli.py ADDED
@@ -0,0 +1,569 @@
1
+ #!/usr/bin/env python3
2
+ """DeepParallel CLI.
3
+
4
+ Commands:
5
+ deepparallel interactive agent chat (default)
6
+ deepparallel chat interactive agent chat (explicit)
7
+ deepparallel run "prompt" one-shot; answer to stdout
8
+ deepparallel tools list available agent tools
9
+ deepparallel info model + backend status
10
+ deepparallel models list registered model
11
+ deepparallel doctor diagnose config + reachability
12
+
13
+ Flags --no-tools (plain chat, no tools) and --yes (auto-approve tool actions)
14
+ apply to chat and run.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ from dataclasses import replace
21
+ from pathlib import Path
22
+
23
+ import click
24
+ import httpx
25
+ from dotenv import load_dotenv
26
+ from rich import box
27
+ from rich.table import Table
28
+
29
+ from deepparallel import __version__, branding
30
+ from deepparallel.agent import (
31
+ guardian_review,
32
+ run_agent,
33
+ verdict_exit_code,
34
+ verdict_severity,
35
+ )
36
+ from deepparallel.backend import Backend, backend_for_deployment, resolve_backend
37
+ from deepparallel.branding import console
38
+ from deepparallel.config import (
39
+ Settings,
40
+ _bool_env,
41
+ load_model_spec,
42
+ load_system_prompt,
43
+ missing_required,
44
+ resolve_settings,
45
+ )
46
+ from deepparallel import licensing
47
+ from deepparallel.fusion import (
48
+ EscalationBackend,
49
+ ReasonAnswerBackend,
50
+ deep_query,
51
+ dual_query,
52
+ )
53
+ from deepparallel.renderer import PlainRenderer, Renderer, RichRenderer
54
+ from deepparallel.tools import get_registry
55
+
56
+
57
+ def _build_messages(history: list[tuple[str, str]], system: str, user_msg: str) -> list[dict]:
58
+ msgs: list[dict] = [{"role": "system", "content": system}]
59
+ for role, content in history:
60
+ msgs.append({"role": role, "content": content})
61
+ msgs.append({"role": "user", "content": user_msg})
62
+ return msgs
63
+
64
+
65
+ def _apply_flags(settings: Settings, no_tools: bool, assume_yes: bool) -> Settings:
66
+ if no_tools:
67
+ settings = replace(settings, tools_enabled=False)
68
+ if assume_yes:
69
+ settings = replace(settings, auto_approve=True)
70
+ return settings
71
+
72
+
73
+ def _effective_backend(base: Backend, settings: Settings, mode: str) -> Backend:
74
+ """Wrap the base backend in a fusion strategy for the given mode.
75
+
76
+ Fusion is a paid (Pro+) feature; on Free it degrades to the single-model
77
+ backend with a one-time upgrade nudge.
78
+ """
79
+ if mode not in ("reason", "escalate"):
80
+ return base
81
+ ok, msg = licensing.check_feature("fusion")
82
+ if not ok:
83
+ branding.info(msg)
84
+ return base
85
+ reasoner = backend_for_deployment(settings, settings.reasoner_deployment)
86
+ if mode == "reason":
87
+ return ReasonAnswerBackend(base, reasoner)
88
+ return EscalationBackend(base, reasoner)
89
+
90
+
91
+ def _wrap_fusion(backend: Backend, settings: Settings) -> Backend:
92
+ """Wrap per settings.fusion_mode (used by the non-interactive paths)."""
93
+ return _effective_backend(backend, settings, settings.fusion_mode)
94
+
95
+
96
+ def _run_deep(settings: Settings, messages: list[dict]) -> None:
97
+ """Heavy multi-model fan-out + judge, one-shot. Chains are shown with
98
+ generic labels (no raw model names) per the brand rule. Paid (Pro+)."""
99
+ ok, msg = licensing.check_feature("deep")
100
+ if not ok:
101
+ branding.error(msg)
102
+ sys.exit(2)
103
+ chains = [
104
+ (f"candidate-{i + 1}", backend_for_deployment(settings, dep))
105
+ for i, dep in enumerate(settings.parallel_deployments)
106
+ ]
107
+ judge = backend_for_deployment(settings, settings.judge_deployment)
108
+
109
+ def on_event(name: str, content: str) -> None:
110
+ sys.stderr.write(f"> {name}: {content[:80]}\n")
111
+ sys.stderr.flush()
112
+
113
+ try:
114
+ result = deep_query(
115
+ messages, chains, judge, settings.temperature, settings.max_tokens, on_event
116
+ )
117
+ except Exception as e: # noqa: BLE001 - surface as friendly message
118
+ branding.error(_translate_error(e))
119
+ sys.exit(1)
120
+ ag = result.get("agreement") or {}
121
+ if ag.get("level") and ag["level"] != "n/a":
122
+ sys.stderr.write(
123
+ f"consensus: {ag['level']} ({ag.get('score')}) [agreement, not correctness]\n"
124
+ )
125
+ if ag["level"] == "low":
126
+ sys.stderr.write("models diverged - candidates:\n")
127
+ for c in result.get("chains", []):
128
+ sys.stderr.write(f" {c['name']}: {c['content'][:120]}\n")
129
+ sys.stderr.flush()
130
+ sys.stdout.write(result["answer"].rstrip("\n") + "\n")
131
+
132
+
133
+ def _run_dual(settings: Settings, messages: list[dict], dual: str, synth: bool) -> None:
134
+ ok, msg = licensing.check_feature("dual")
135
+ if not ok:
136
+ branding.error(msg)
137
+ sys.exit(2)
138
+ names = [s.strip() for s in dual.split(",") if s.strip()] if dual else []
139
+ if len(names) >= 2:
140
+ la, ra, lname, rname = names[0], names[1], names[0], names[1]
141
+ else:
142
+ la, ra, lname, rname = (
143
+ settings.deployment,
144
+ settings.reasoner_deployment,
145
+ "primary",
146
+ "reasoner",
147
+ )
148
+ left = (lname, backend_for_deployment(settings, la))
149
+ right = (rname, backend_for_deployment(settings, ra))
150
+ judge = backend_for_deployment(settings, settings.judge_deployment) if synth else None
151
+ try:
152
+ out = dual_query(messages, left, right, settings.temperature, settings.max_tokens, judge)
153
+ except Exception as e: # noqa: BLE001 - surface as friendly message
154
+ branding.error(_translate_error(e))
155
+ sys.exit(1)
156
+ sys.stdout.write(f"## {out['left']['name']}\n{out['left']['content']}\n\n")
157
+ sys.stdout.write(f"## {out['right']['name']}\n{out['right']['content']}\n")
158
+ if out["synthesis"]:
159
+ sys.stdout.write(f"\n## synthesis\n{out['synthesis']}\n")
160
+
161
+
162
+ def _build_guardian(settings: Settings) -> Backend | None:
163
+ """A second model that reviews edits before they apply (interactive only).
164
+
165
+ Guardian is a paid (Pro+) feature; disabled on Free.
166
+ """
167
+ if not settings.guardian_enabled:
168
+ return None
169
+ ok, msg = licensing.check_feature("guardian")
170
+ if not ok:
171
+ branding.info(msg)
172
+ return None
173
+ return backend_for_deployment(settings, settings.guardian_deployment)
174
+
175
+
176
+ def _make_renderer(*, force_plain: bool, assume_yes: bool = False) -> Renderer:
177
+ plain = force_plain or _bool_env("DEEPPARALLEL_PLAIN", False) or not sys.stdout.isatty()
178
+ if plain:
179
+ return PlainRenderer(assume_yes=assume_yes)
180
+ return RichRenderer()
181
+
182
+
183
+ def _require_ready(settings: Settings) -> Backend:
184
+ missing = missing_required(settings)
185
+ if missing:
186
+ branding.error(f"missing required env: {', '.join(missing)} (backend={settings.backend})")
187
+ sys.exit(1)
188
+ return resolve_backend(settings)
189
+
190
+
191
+ def _translate_error(e: Exception) -> str:
192
+ if isinstance(e, httpx.HTTPStatusError):
193
+ code = e.response.status_code
194
+ if code == 401:
195
+ return "authentication failed (401) - check the API key."
196
+ if code == 404:
197
+ return "model endpoint not found (404) - check the backend configuration."
198
+ return f"backend returned HTTP {code}."
199
+ return f"{e.__class__.__name__}: {e}"
200
+
201
+
202
+ def _print_info(settings: Settings) -> None:
203
+ spec = load_model_spec()
204
+ table = Table(box=box.SIMPLE_HEAVY, show_header=False, pad_edge=False)
205
+ table.add_column("k", style="grey50")
206
+ table.add_column("v", style="white")
207
+ table.add_row("model", spec.label)
208
+ table.add_row("backend", settings.backend)
209
+ table.add_row("context", str(spec.context_window))
210
+ table.add_row("license", f"{spec.license} · {spec.license_url}")
211
+ console.print(table)
212
+
213
+
214
+ def _stream_once(backend: Backend, settings: Settings, messages: list[dict]) -> None:
215
+ """Plain-chat one-shot (no tools): stream tokens to stdout."""
216
+ try:
217
+ for channel, text in backend.stream_chat(
218
+ messages, settings.temperature, settings.max_tokens
219
+ ):
220
+ if channel == "thinking" and not settings.show_thinking:
221
+ continue
222
+ sys.stdout.write(text)
223
+ sys.stdout.flush()
224
+ sys.stdout.write("\n")
225
+ except Exception as e: # noqa: BLE001 - surface as friendly message
226
+ branding.error(_translate_error(e))
227
+ sys.exit(1)
228
+
229
+
230
+ def _stream_repl(backend: Backend, settings: Settings) -> None:
231
+ """Plain-chat interactive loop (no tools)."""
232
+ system = load_system_prompt()
233
+ history: list[tuple[str, str]] = []
234
+ while True:
235
+ try:
236
+ user_msg = console.input(branding.user_prefix() + "› ").strip()
237
+ except (EOFError, KeyboardInterrupt):
238
+ console.print()
239
+ break
240
+ if not user_msg:
241
+ continue
242
+ if user_msg in {"/quit", "/exit", ":q"}:
243
+ break
244
+ if user_msg == "/help":
245
+ branding.info("/quit · /reset · /info · anything else is a prompt")
246
+ continue
247
+ if user_msg == "/reset":
248
+ history.clear()
249
+ branding.info("conversation cleared.")
250
+ continue
251
+ if user_msg == "/info":
252
+ _print_info(settings)
253
+ continue
254
+
255
+ messages = _build_messages(history, system, user_msg)
256
+ console.print(branding.model_prefix() + "›", end=" ")
257
+ reply: list[str] = []
258
+ try:
259
+ for channel, text in backend.stream_chat(
260
+ messages, settings.temperature, settings.max_tokens
261
+ ):
262
+ if channel == "thinking":
263
+ if settings.show_thinking:
264
+ branding.thinking(text)
265
+ continue
266
+ console.print(text, end="", soft_wrap=True, highlight=False)
267
+ reply.append(text)
268
+ console.print()
269
+ except Exception as e: # noqa: BLE001 - surface as friendly message
270
+ console.print()
271
+ branding.error(_translate_error(e))
272
+ continue
273
+
274
+ history.append(("user", user_msg))
275
+ history.append(("assistant", "".join(reply)))
276
+
277
+ branding.attribution_footer()
278
+
279
+
280
+ _DIAL = {"/fast": "off", "/fuse": "reason", "/escalate": "escalate"}
281
+
282
+
283
+ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> None:
284
+ """Interactive agentic loop: tools enabled, conversation persists. The dial
285
+ (/fast //fuse //escalate //deep) swaps the active fusion mode live."""
286
+ registry = get_registry()
287
+ guardian = _build_guardian(settings)
288
+ system = load_system_prompt()
289
+ messages: list[dict] = [{"role": "system", "content": system}]
290
+ mode = settings.fusion_mode if settings.fusion_mode in ("reason", "escalate") else "off"
291
+ deep_next = False
292
+ while True:
293
+ tag = f"[{mode}] " if mode != "off" else ""
294
+ try:
295
+ user_msg = console.input(f"{tag}{branding.user_prefix()}› ").strip()
296
+ except (EOFError, KeyboardInterrupt):
297
+ console.print()
298
+ break
299
+ if not user_msg:
300
+ continue
301
+ if user_msg in {"/quit", "/exit", ":q"}:
302
+ break
303
+ if user_msg == "/help":
304
+ branding.info(
305
+ "/quit · /reset · /info · /tools · /fast //fuse //escalate //deep · prompt"
306
+ )
307
+ continue
308
+ if user_msg == "/reset":
309
+ messages = [{"role": "system", "content": system}]
310
+ branding.info("conversation cleared.")
311
+ continue
312
+ if user_msg == "/info":
313
+ _print_info(settings)
314
+ continue
315
+ if user_msg in _DIAL:
316
+ mode = _DIAL[user_msg]
317
+ branding.info(f"mode: {mode}")
318
+ continue
319
+ if user_msg == "/deep":
320
+ deep_next = True
321
+ branding.info("next prompt runs as a multi-model deep query")
322
+ continue
323
+
324
+ messages.append({"role": "user", "content": user_msg})
325
+ if deep_next:
326
+ deep_next = False
327
+ _run_deep(settings, list(messages))
328
+ continue
329
+ try:
330
+ run_agent(
331
+ _effective_backend(backend, settings, mode),
332
+ registry,
333
+ messages,
334
+ settings,
335
+ renderer,
336
+ interactive=True,
337
+ auto_approve=settings.auto_approve,
338
+ stream=True,
339
+ guardian=guardian,
340
+ )
341
+ if mode in ("reason", "escalate"):
342
+ branding.info("reasoned by CroweLM Reason · answered by DeepParallel")
343
+ except Exception as e: # noqa: BLE001 - surface as friendly message
344
+ renderer.error(_translate_error(e))
345
+
346
+ branding.attribution_footer()
347
+
348
+
349
+ def _chat_loop(settings: Settings) -> None:
350
+ backend = _require_ready(settings)
351
+ ok, msg = backend.check()
352
+ if settings.tools_enabled:
353
+ renderer = _make_renderer(force_plain=False, assume_yes=settings.auto_approve)
354
+ renderer.welcome(
355
+ msg,
356
+ version=__version__,
357
+ tool_count=len(get_registry().list_all()),
358
+ fusion_modes=("reason", "escalate", "deep", "dual"),
359
+ )
360
+ if not ok:
361
+ renderer.error(msg)
362
+ sys.exit(1)
363
+ _agent_repl(backend, settings, renderer) # raw backend; dial re-wraps live
364
+ else:
365
+ if not ok:
366
+ branding.welcome(backend_label=msg)
367
+ branding.error(msg)
368
+ sys.exit(1)
369
+ branding.welcome(backend_label=msg)
370
+ _stream_repl(_wrap_fusion(backend, settings), settings)
371
+
372
+
373
+ @click.group(
374
+ invoke_without_command=True,
375
+ context_settings={"help_option_names": ["-h", "--help"]},
376
+ )
377
+ @click.version_option(__version__, prog_name="deepparallel")
378
+ @click.option("--temperature", "-t", default=None, type=float, help="Sampling temperature.")
379
+ @click.pass_context
380
+ def main(ctx: click.Context, temperature: float | None) -> None:
381
+ """DeepParallel - a focused agentic CLI for the DeepParallel model."""
382
+ load_dotenv()
383
+ ctx.ensure_object(dict)
384
+ settings = resolve_settings()
385
+ if temperature is not None:
386
+ settings = replace(settings, temperature=temperature)
387
+ ctx.obj["settings"] = settings
388
+ if ctx.invoked_subcommand is None:
389
+ _chat_loop(settings)
390
+
391
+
392
+ @main.command()
393
+ @click.option("--no-tools", is_flag=True, help="Disable tools (plain chat).")
394
+ @click.option("--yes", "-y", "assume_yes", is_flag=True, help="Auto-approve tool actions.")
395
+ @click.option(
396
+ "--fuse",
397
+ type=click.Choice(["reason", "escalate", "off"]),
398
+ default=None,
399
+ help="Stack a reasoner with the answerer.",
400
+ )
401
+ @click.pass_context
402
+ def chat(ctx: click.Context, no_tools: bool, assume_yes: bool, fuse: str | None) -> None:
403
+ """Start an interactive chat session."""
404
+ settings = _apply_flags(ctx.obj["settings"], no_tools, assume_yes)
405
+ if fuse is not None:
406
+ settings = replace(settings, fusion_mode=fuse)
407
+ _chat_loop(settings)
408
+
409
+
410
+ @main.command()
411
+ @click.option("--no-tools", is_flag=True, help="Disable tools (plain chat).")
412
+ @click.option("--yes", "-y", "assume_yes", is_flag=True, help="Auto-approve tool actions.")
413
+ @click.option(
414
+ "--fuse",
415
+ type=click.Choice(["reason", "escalate", "off"]),
416
+ default=None,
417
+ help="Stack a reasoner with the answerer.",
418
+ )
419
+ @click.option("--deep", is_flag=True, help="Heavy multi-model fan-out + judge (slow).")
420
+ @click.option(
421
+ "--dual",
422
+ default=None,
423
+ help="Compare two deployments 'A,B' (default: primary vs reasoner).",
424
+ )
425
+ @click.option("--synth", is_flag=True, help="With --dual, also synthesize a merged answer.")
426
+ @click.argument("prompt", nargs=-1, required=True)
427
+ @click.pass_context
428
+ def run(
429
+ ctx: click.Context,
430
+ no_tools: bool,
431
+ assume_yes: bool,
432
+ fuse: str | None,
433
+ deep: bool,
434
+ dual: str | None,
435
+ synth: bool,
436
+ prompt: tuple[str, ...],
437
+ ) -> None:
438
+ """Run a single prompt; the answer goes to stdout."""
439
+ settings = _apply_flags(ctx.obj["settings"], no_tools, assume_yes)
440
+ if fuse is not None:
441
+ settings = replace(settings, fusion_mode=fuse)
442
+ backend = _require_ready(settings)
443
+ system = load_system_prompt()
444
+ messages = _build_messages([], system, " ".join(prompt))
445
+ if deep:
446
+ _run_deep(settings, messages)
447
+ return
448
+ if dual is not None:
449
+ _run_dual(settings, messages, dual, synth)
450
+ return
451
+ backend = _wrap_fusion(backend, settings)
452
+ if not settings.tools_enabled:
453
+ _stream_once(backend, settings, messages)
454
+ return
455
+ renderer = PlainRenderer(assume_yes=settings.auto_approve)
456
+ try:
457
+ run_agent(
458
+ backend,
459
+ get_registry(),
460
+ messages,
461
+ settings,
462
+ renderer,
463
+ interactive=False,
464
+ auto_approve=settings.auto_approve,
465
+ )
466
+ except Exception as e: # noqa: BLE001 - surface as friendly message
467
+ branding.error(_translate_error(e))
468
+ sys.exit(1)
469
+
470
+
471
+ @main.command()
472
+ @click.option("--diff", "as_diff", is_flag=True, help="Read a unified diff from stdin.")
473
+ @click.argument("path", required=False)
474
+ @click.pass_context
475
+ def review(ctx: click.Context, as_diff: bool, path: str | None) -> None:
476
+ """Independent cross-model review of a code change (the Guardian, as a gate).
477
+
478
+ Reviews a file (PATH) or a unified diff (--diff, from stdin) with a second
479
+ model and prints a verdict. Exit code: 0 safe, 1 risky, 2 bug - so it can
480
+ gate a commit or PR. Paid (Pro+).
481
+ """
482
+ settings: Settings = ctx.obj["settings"]
483
+ ok, msg = licensing.check_feature("review")
484
+ if not ok:
485
+ branding.error(msg)
486
+ sys.exit(3)
487
+ if as_diff:
488
+ content = sys.stdin.read()
489
+ elif path:
490
+ try:
491
+ content = Path(path).expanduser().read_text(encoding="utf-8")
492
+ except OSError as e:
493
+ branding.error(f"cannot read {path}: {e}")
494
+ sys.exit(3)
495
+ else:
496
+ branding.error("provide a PATH or --diff (with a diff on stdin)")
497
+ sys.exit(3)
498
+ _require_ready(settings) # validates creds / exits if missing
499
+ guardian = backend_for_deployment(settings, settings.guardian_deployment)
500
+ verdict = guardian_review(guardian, content[:8000])
501
+ severity = verdict_severity(verdict)
502
+ glyph = {"safe": branding.CHECK, "risky": "!", "bug": branding.CROSS}.get(severity, "?")
503
+ console.print(f"[bold]{glyph} {severity.upper()}[/] {verdict or '(no verdict)'}")
504
+ sys.exit(verdict_exit_code(verdict))
505
+
506
+
507
+ @main.command(name="tools")
508
+ def tools_cmd() -> None:
509
+ """List the agent tools available to DeepParallel."""
510
+ table = Table(box=box.SIMPLE_HEAVY)
511
+ table.add_column("tool", style="bold")
512
+ table.add_column("danger")
513
+ table.add_column("description")
514
+ for m in get_registry().list_all():
515
+ table.add_row(m.name, "!" if m.dangerous else "", m.description.splitlines()[0])
516
+ console.print(table)
517
+
518
+
519
+ @main.command()
520
+ @click.pass_context
521
+ def info(ctx: click.Context) -> None:
522
+ """Show model and backend status."""
523
+ settings: Settings = ctx.obj["settings"]
524
+ _print_info(settings)
525
+ missing = missing_required(settings)
526
+ if missing:
527
+ console.print(f"[bold red]config incomplete[/] · missing {', '.join(missing)}")
528
+ else:
529
+ ok, msg = resolve_backend(settings).check()
530
+ console.print(
531
+ f"[bright_green]backend ok[/] · {msg}" if ok else f"[bold red]backend down[/] · {msg}"
532
+ )
533
+ branding.attribution_footer()
534
+
535
+
536
+ @main.command()
537
+ def models() -> None:
538
+ """List registered models (single-model CLI)."""
539
+ spec = load_model_spec()
540
+ table = Table(box=box.SIMPLE_HEAVY)
541
+ table.add_column("name", style="bold")
542
+ table.add_column("label")
543
+ table.add_column("family")
544
+ table.add_row(spec.name, spec.label, spec.base_family)
545
+ console.print(table)
546
+ branding.attribution_footer()
547
+
548
+
549
+ @main.command()
550
+ @click.pass_context
551
+ def doctor(ctx: click.Context) -> None:
552
+ """Diagnose configuration and backend reachability."""
553
+ settings: Settings = ctx.obj["settings"]
554
+ console.print(f"[bold]backend[/]: {settings.backend}")
555
+ missing = missing_required(settings)
556
+ if missing:
557
+ branding.error(f"missing env: {', '.join(missing)}")
558
+ sys.exit(1)
559
+ console.print("[bright_green]env ok[/]")
560
+ ok, msg = resolve_backend(settings).check()
561
+ if ok:
562
+ console.print(f"[bright_green]reachable[/] · {msg}")
563
+ else:
564
+ branding.error(msg)
565
+ sys.exit(1)
566
+
567
+
568
+ if __name__ == "__main__":
569
+ main()